diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml new file mode 100644 index 0000000000..6e9974fdce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +2026-07-27-trajectory-inspection-ledger.md: 8c2a7c42b7898776de5d42459b09c0fb1737ec0b +2026-07-27-trajectory-inspection-ledger.zh.md: 6c5733046dc2cfd3fd2bd005f4cf5c2d2bd110af diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md new file mode 100644 index 0000000000..8c2a7c42b7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -0,0 +1,43 @@ +# Agent Note: Trajectory inspection ledger + +Status: implemented + +English | [中文](2026-07-27-trajectory-inspection-ledger.zh.md) + +## Problem + +Trajectory has to make prose, machine payloads, token usage, timing, and nested tool activity readable in the same viewport. The earlier stacked Turn and Step cards preserved hierarchy but spent too much vertical space on repeated chrome, while a completely flat table would erase the causal structure that makes a trajectory useful. Role colors also risked borrowing success and warning semantics, which made visual decoration indistinguishable from runtime state. + +## Decision + +**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. +- 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. +- 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. +- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory subscribes to that source, exhausts its paging only while mounted, and lazily derives its event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer. +- 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. Complete history makes global Request numbering and cumulative usage session-wide rather than tail-window-relative. +- 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, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. 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. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. 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. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. + +## Alternatives considered + +**Copy Vite DevTools fonts, colors, glass surfaces, or component shapes.** Rejected: those choices express a different product identity. The implementation only adopts the transferable method: neutral structure, semantic accents, machine-data typography, dense scanning, and shadows reserved for floating layers. + +**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. + +**Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows. + +**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. + +**Keep timing in a separate Waterfall tab.** Rejected: the placeholder summarized node counts rather than record timing and forced users to switch away from the rows they wanted to focus. A full-domain Overview keeps timing and filtered records in one visual context. + +**Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces. + +## 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 main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin projection, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md new file mode 100644 index 0000000000..6c5733046d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -0,0 +1,43 @@ +# Agent Note:轨迹检查记录表 + +Status: implemented + +[English](2026-07-27-trajectory-inspection-ledger.md) | 中文 + +## 问题 + +轨迹视图需要在同一视口内清晰呈现正文、机器载荷、token 用量、计时数据和嵌套工具活动。此前堆叠式的轮次与步骤卡片虽然保留了层级,却在重复界面框架上耗费了太多垂直空间;完全扁平化的表格又会抹去因果结构,而这种结构正是轨迹视图的价值所在。角色配色还可能借用成功与警告语义,使视觉装饰与运行时状态无法区分。 + +## 决策 + +**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** + +- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 +- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 +- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 +- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 +- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 订阅该数据源,仅在挂载期间补齐全部历史,并按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。 +- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。完整历史使全局请求编号和累计用量以整个会话为范围,而不是相对于末尾窗口。 +- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 +- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 +- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 + +## 曾考虑的替代方案 + +**照搬 Vite DevTools 的字体、颜色、玻璃表面或组件形状。** 不予采纳:这些选择表达的是另一种产品身份。实现仅吸收可迁移的方法,即中性结构、语义强调色、机器数据排版、紧凑扫读,以及只为浮层保留阴影。 + +**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 + +**不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。 + +**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 + +**将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。 + +**修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。 + +## 后果 + +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间与耗时数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定投影、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 区域与检查器。 diff --git a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.i18n.yaml new file mode 100644 index 0000000000..7505370b2b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md +2026-07-28-local-json-tree-renderer.md: 5e8e6d6968319ada53e65a48e9e03b297dc0141c +2026-07-28-local-json-tree-renderer.zh.md: 221880391da05551d05ad9e3dadb909e2e74fa68 diff --git a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md new file mode 100644 index 0000000000..5e8e6d6968 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md @@ -0,0 +1,32 @@ +# Agent Note: Local JSON tree renderer + +Status: implemented + +English | [中文](2026-07-28-local-json-tree-renderer.zh.md) + +## Problem + +The read-only JSON inspector used by the [trajectory ledger](../feature/2026-07-27-trajectory-inspection-ledger.md) needs compact object and array previews, explicit array paths for copy actions, fixed-open and collapsible root modes, and keyboard navigation. `react-json-view-lite` exposes neither custom node rendering nor row identity, so satisfying those requirements through that dependency requires a package-manager patch against compiled distribution files and DOM traversal that reconstructs data paths from visible labels. The patch behaves as an untyped fork while its source maps and upstream source remain unchanged. + +## Decision + +`JsonTree` owns its recursive presentation in `dsh-client-ui-primitives`. + +- Each rendered row receives its value and property path directly. Object keys and array indexes extend that path during recursion, so copy actions never recover application data from rendered DOM text. +- Expandable rows render the compact preview locally and mount child rows only while expanded. `expandTopLevel` selects between a fixed-open bracket frame and a collapsible root node without changing the public component contract. +- The tree keeps one tabbable expander among visible nodes. Pointer activation claims that tab stop; Up and Down move it cyclically, while Left and Right collapse or expand the focused node. +- `react-json-view-lite` is not a package dependency and has no pnpm patch. Focused component tests pin previews, expansion, keyboard focus, and array copy paths. + +## Alternatives considered + +**Keep the distribution patch.** Rejected because the application-specific renderer and array identity contract would remain hidden in generated third-party files, and every dependency update would require reviewing a fork without matching source maps. + +**Use the upstream renderer without previews.** Rejected because `{…}` and `[…]` discard the compact payload context that the trajectory inspector uses for scanning. + +**Inject previews and row metadata after render.** Rejected because effects or mutation observers would depend on the same private DOM structure while splitting one row between React ownership and imperative mutation. + +**Adopt a larger JSON viewer.** Rejected because editing, search, and theme systems are outside the current read-only contract; the added dependency surface would not remove the inspector-specific copy and layout code. + +## Consequences + +The JSON inspector has one source-level owner, explicit data flow, accurate array paths, and no patched dependency. The package now owns recursive rendering, expansion state, ARIA tree structure, and roving focus behavior, so changes to those semantics require focused component coverage. The implementation remains intentionally read-only and limited to the preview, navigation, and copy behavior used by current consumers. diff --git a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md new file mode 100644 index 0000000000..221880391d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md @@ -0,0 +1,32 @@ +# Agent Note:本地 JSON 树渲染器 + +Status: implemented + +[English](2026-07-28-local-json-tree-renderer.md) | 中文 + +## 问题 + +[轨迹检查记录表](../feature/2026-07-27-trajectory-inspection-ledger.md)使用的只读 JSON 检查器需要提供紧凑的对象和数组预览、供复制操作使用的明确数组路径、固定展开与可折叠两种根节点模式,以及键盘导航。`react-json-view-lite` 既不提供自定义节点渲染,也不提供行标识;要通过该依赖满足这些要求,就必须使用包管理器为编译后的发布文件打补丁,并遍历 DOM,从可见标签中还原数据路径。该补丁实际上相当于一个不受类型约束的 fork,但其源码映射与上游源码均未同步修改。 + +## 决策 + +`dsh-client-ui-primitives` 中的 `JsonTree` 自行负责递归呈现。 + +- 每个渲染行都直接接收自身的值和属性路径。递归时,对象键和数组索引会附加到路径末尾,因此复制操作无需再从 DOM 渲染文本中反向还原应用数据。 +- 可展开行在本地渲染紧凑预览,仅在展开时挂载子行。`expandTopLevel` 可选择固定展开的括号框架或可折叠根节点,而不改变组件的公开契约。 +- 树中所有可见节点仅保留一个可通过 Tab 键聚焦的展开控件。使用指针激活控件后,该控件会成为 Tab 键焦点位置;上、下方向键循环移动焦点,左、右方向键折叠或展开当前焦点所在节点。 +- `react-json-view-lite` 不在 `dsh-client-ui-primitives` 的依赖项中,也没有 pnpm 补丁。针对性组件测试锁定预览、展开、键盘焦点和数组复制路径。 + +## 曾考虑的替代方案 + +**保留发布文件补丁。** 不予采纳:应用专用的渲染逻辑和数组标识契约仍会隐藏在生成的第三方文件中;每次更新依赖,都必须评审一个没有配套源码映射的 fork。 + +**直接使用不提供预览的上游渲染器。** 不予采纳:`{…}` 和 `[…]` 会丢失轨迹检查器扫读时所需的紧凑载荷上下文。 + +**渲染后注入预览和行元数据。** 不予采纳:Effect 或 MutationObserver 仍会依赖同一套私有 DOM 结构,同时使 React 和命令式变更机制分别负责同一行的不同部分。 + +**采用更大型的 JSON 查看器。** 不予采纳:编辑、搜索与主题系统不在当前只读契约范围内;扩大依赖范围也无法去除检查器专用的复制和布局代码。 + +## 后果 + +JSON 检查器在源码层由单一实现负责,具有显式数据流和准确的数组路径,且没有经过补丁修改的依赖。`dsh-client-ui-primitives` 负责递归渲染、展开状态、ARIA 树结构和焦点循环移动行为,因此修改这些语义时必须提供针对性组件测试。实现有意保持只读,仅包含当前消费方使用的预览、导航与复制行为。 diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index 6214332166..b7ab11f2c0 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -5,8 +5,9 @@ // the code-variant parent row titled by the model-authored description, its // three always-visible nested sub-rows (bash through the sample registration, // read through GenericToolCard, the failing read wearing the error state), -// the expanded program body, inert bash / file-link sub-row gestures, and -// the trajectory/waterfall tabs' sub-call cells and timing lanes. +// the expanded program body, inert bash / file-link sub-row gestures, +// details-panel resolution of a sub-callId, and the Trajectory tab's sub-call +// cells and timing overview. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -197,7 +198,7 @@ it('expands the code row into the program body; sub-row clicks do not open detai `) }) -it('trajectory and waterfall surface the run_code sub-calls with real timing', async () => { +it('trajectory surfaces run_code sub-calls in the ledger and timing overview', async () => { boot() await openFixtureSession() @@ -208,51 +209,30 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a }, { timeout: 10_000 }) const subCells = [...document.querySelectorAll('[data-kind="subtool"]')] expect({ - // Three Sub cells nested under the run_code Tool cell, in dispatch order, - // each with a real +N.Ns own-duration off the start/settle pair (the - // fixture spaces every event 800ms apart — never the em dash). + // Three Subtool cells nested under the run_code Tool cell in dispatch + // order, each paired with its result preview. subCells: subCells.map(cell => visibleText(cell)), }).toMatchInlineSnapshot(` { "subCells": [ - "#49Subbash · {"command":"ls notes","description":"List notes"}+0.8s", - "#50Subread · {"path":"notes/demo.txt"}+0.8s", - "#51Subread · {"path":"notes/missing.txt"}+0.8s", + "SUBTOOLbash{"command":"ls notes","description":"List notes"}→demo.txt new-demo.txt", + "SUBTOOLread{"path":"notes/demo.txt"}→hello fixture", + "SUBTOOLread{"path":"notes/missing.txt"}→error", ], } `) - // Waterfall: each sub-call draws a measured lane scaled into the parent - // turn's dispatch window. - fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' })) - await waitFor(() => { - expect(document.querySelector('[data-subspan]')).not.toBeNull() - }, { timeout: 10_000 }) - const lanes = [...document.querySelectorAll('[data-subspan]')] + const timelineSubCalls = [...document.querySelectorAll('[data-timeline-span="subtool"]')] expect({ - lanes: lanes.map(lane => ({ - label: visibleText(lane.querySelector('[class*="subTag"]') ?? lane), - title: lane.querySelector('[data-timing]')?.getAttribute('title'), - timing: lane.querySelector('[data-timing]')?.getAttribute('data-timing'), - })), + count: timelineSubCalls.length, + measured: timelineSubCalls.map(span => span.getAttribute('title')?.endsWith(' · 800 ms')), }).toMatchInlineSnapshot(` { - "lanes": [ - { - "label": "bash", - "timing": "measured", - "title": "bash · 0.80s", - }, - { - "label": "read", - "timing": "measured", - "title": "read · 0.80s", - }, - { - "label": "read", - "timing": "measured", - "title": "read · 0.80s", - }, + "count": 3, + "measured": [ + true, + true, + true, ], } `) diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index a7f493e448..9826d35f7c 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -118,14 +118,14 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1) }, 60_000) - it.skipIf(MODE === 'record')('a bash sub-row click leaves the details panel collapsed', async () => { + it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel open', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details')) const nest = page.locator('[data-subcalls]').first() - const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() - expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() + const frame = page.locator('[style*="grid-template-columns"]').first() + expect(await frame.getAttribute('data-details-collapsed')).toBeNull() await nest.locator('[data-sample="bash-global"]').first().click() - // Tool rows no longer open details; the column stays width 0. - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + // Tool rows do not drive layout geometry; the Session's default panel stays open. + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull() }) it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => { diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 96117c21bf..0f42d911aa 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -1,11 +1,11 @@ -// Web e2e scenarios: navigation & panes — the view tabs (Trajectory / -// Waterfall) and sidebar search, all over ONE rich two-turn seeded fixture -// rendered purely from the log (the seeded-history pattern: zero model calls -// in replay, so every surface here is the client fold + host history RPC, -// not replay binding). The seed is recorded live under the standard -// discipline: turn 1 produces a bash call plus two parallel reads in one -// assistant message (tool-call density for the trajectory/waterfall lanes), -// turn 2 a markdown-rich reply (a second turn so the waterfall has two lanes). +// Web e2e scenarios: navigation & panes — the Trajectory view and timing +// overview, its local details inspector, and sidebar search, all over ONE rich +// two-turn seeded fixture rendered purely from the log (the seeded-history +// pattern: zero model calls in replay, so every surface here is the client +// fold + host history RPC, not replay binding). The seed is recorded live +// under the standard discipline: turn 1 produces a bash call plus two +// parallel reads in one assistant message (tool-call density for the +// trajectory ledger/timing lanes), turn 2 a markdown-rich reply. import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -23,7 +23,6 @@ import { saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') -const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' @@ -39,6 +38,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { let browser: Browser let page: Page let tripwire: ReturnType + let slotErrors: string[] beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -58,6 +58,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) tripwire = watchConsole(page) + slotErrors = [] + page.on('console', (message) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) { + slotErrors.push(message.text()) + } + }) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -123,55 +129,68 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) }, 60_000) - it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => { + it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) await page.getByRole('tab', { name: 'Trajectory' }).click() - // Two sticky turn sections; turn 1's step group summarizes its tool mix - // (bash + the two parallel reads collapse to 'bash read×2'). - await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1) - await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1) + await page.waitForTimeout(100) + expect({ + pageErrors: tripwire.pageErrors, + slotErrors, + warnings: tripwire.warnings, + }).toEqual({ + pageErrors: [], + slotErrors: [], + warnings: [], + }) + // Turn rules partition the ledger without restoring a separate header row. + await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2) + await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0) + await page.locator('tr[data-kind="tool"]').first().click() + await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1) + await page.getByRole('tab', { name: 'Result' }).click() + await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + await page.getByRole('complementary', { name: 'Event details' }) + .getByRole('button', { name: 'Close details' }).click() }, 60_000) - it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall')) - await page.getByRole('tab', { name: 'Waterfall' }).click() - // The stats header rides the waterfall body. The span fold counts THREE - // spans for this two-turn log: only assistant/steering nodes carry a turn - // number, so the first user message lands in a turn-0 prologue span (a - // P-I placeholder shape — pinned as-is; real spans are deferred to - // P-III per the view's deviation ledger). Calls: bash + two reads. - await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1) - // One lane per span, tagged by turn number, prologue included. - for (const tag of ['turn 0', 'turn 1', 'turn 2']) { - await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1) - } - const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) - .split(SEED_ID).join('{{seededId}}') - await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) + const plot = page.getByLabel('Timeline overview; drag horizontally to focus events') + const before = await page.locator('tr[data-kind]').count() + const box = await plot.boundingBox() + if (box === null) throw new Error('trajectory timeline plot has no layout box') + await page.mouse.move(box.x + box.width * 0.55, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2) + await page.mouse.up() + await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 }) + .toBeGreaterThan(0) + await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before) + await plot.click({ button: 'right' }) + await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) - it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => { + it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column open', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) await page.getByRole('tab', { name: 'Chat' }).click() const bashRow = page.locator('[data-sample="bash-global"]').first() await bashRow.waitFor({ timeout: 15_000 }) - const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() - expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() + const frame = page.locator('[style*="grid-template-columns"]').first() + expect(await frame.getAttribute('data-details-collapsed')).toBeNull() await bashRow.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull() // The card's own controls are outside the summary row and must not open // details either — the terminal card is read in place. await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull() // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull() }, 60_000) it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => { @@ -257,9 +276,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.pageErrors).toEqual([]) + expect(slotErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'terminal-card.expected.md', + 'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md', ]) }) }) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 6ecdb683b3..8689a74fc2 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -104,7 +104,7 @@ describe('web e2e: resident question composer round trip', () => { const inner = child.getBoundingClientRect() return Math.max(box.top - inner.top, inner.bottom - box.bottom) }))) - const list = rows[0]?.parentElement ?? null + const list = card.querySelector('[data-question-scroll]') return { rows: rows.length, spill: Math.max(...spill), diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index d30b3d7c39..80d84d7b2f 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -139,10 +139,10 @@ describe('web e2e: seeded history renders through cold resume', () => { // capture; still zero model calls. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() - expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() + const frame = page.locator('[style*="grid-template-columns"]').first() + expect(await frame.getAttribute('data-details-collapsed')).toBeNull() await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull() // Path label survives from the recorded args (a.txt). await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 42b73c1a31..aceb002d05 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -357,16 +357,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke requireDist() sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-')) const port = await probeFreePort() - // tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. cwd is a - // temp dir (persistenceRoot is cwd-relative), so tsx needs the repo's loader - // and tsconfig paths pointed at explicitly. + // tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate + // the global Harness home inside the temp world; tsx also needs the repo's + // loader and tsconfig paths pointed at explicitly. const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href child = spawn( process.execPath, ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)], { cwd: sessionsDir, - env: { ...process.env, TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json') }, + env: { + ...process.env, + DSH_HOME: join(sessionsDir, '.dsh'), + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, stdio: ['ignore', 'pipe', 'pipe'], }, ) @@ -440,17 +444,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '04-round-complete') }, 150_000) - it('4 view tabs: Chat / Trajectory / Waterfall all switch', async () => { + it('view tabs: Chat and Trajectory switch', async () => { onTestFailed(() => saveFailureShot(page, 'w5-tabs')) await page.locator('button', { hasText: /Trajectory/i }).first().click() await screen(page, '05-trajectory-tab') - await page.locator('button', { hasText: /Waterfall/i }).first().click() - await screen(page, '06-waterfall-tab') + await page.getByLabel('Trajectory timeline').waitFor() + await expect.poll(() => page.getByRole('tab', { name: 'Waterfall' }).count()).toBe(0) await page.locator('button', { hasText: /^Chat$/i }).first().click() await screen(page, '07-back-to-chat') }) - it('5 bash differential rendering: tool row click leaves the details column collapsed', async () => { + it('5 bash differential rendering: tool row click leaves the default details column open', async () => { onTestFailed(() => saveFailureShot(page, 'w5-tool-details')) const input = page.locator('textarea').first() await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果') @@ -462,11 +466,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke const toolRow = page.locator('[data-sample="bash-global"]') await toolRow.waitFor({ timeout: 120_000 }) await screen(page, '08-bash-round') - expect(await detailsTrack(page)).toBe(0) + expect(await detailsTrack(page)).toBe(360) await toolRow.click() - // Tool rows no longer drive layout.openDetails; the column stays closed. - expect(await detailsTrack(page)).toBe(0) - await screen(page, '09-details-closed') + // Tool rows no longer drive layout.openDetails; the default column stays open. + expect(await detailsTrack(page)).toBe(360) + await screen(page, '09-details-open') }, 150_000) it('6 sidebar drag widens the column and resets across reload', async () => { diff --git a/apps/web/tests/snapshots/code-mode-round/session.jsonl b/apps/web/tests/snapshots/code-mode-round/session.jsonl index 6e9e481129..b49160e3f9 100644 --- a/apps/web/tests/snapshots/code-mode-round/session.jsonl +++ b/apps/web/tests/snapshots/code-mode-round/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785013630399,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785013630411,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1c4a6a0f-a7bd-4642-919d-6ed7395df98b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785013630418,"data":{"title":"Using ONE run_code program: run","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785013630479,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785013630480,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -12,13 +12,13 @@ {"type":"assistant/chunk","seq":202,"time":1785013633103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}}} {"type":"assistant/chunk","seq":203,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}}} {"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} +{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0d217af9-3bb3-425b-b53e-7ab0366f0c66"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} {"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}} {"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}} {"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}} {"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}} {"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}} -{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"} +{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_6VNoF1gDSerTBKoCfYSH3765"},"content":[{"type":"tool-result","toolCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false}],"role":"user","id":"1580d4a8-5760-415f-984f-f93927271d3f"}},"sourceEventSeqs":[206],"surfaceOp":"append"} {"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}} {"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":214,"time":1785013633985,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -30,6 +30,6 @@ {"type":"assistant/chunk","seq":233,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":234,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}}}} {"type":"assistant/chunk","seq":235,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"} +{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3037ec34-6c2a-4e01-91eb-a0ef89ce0d15"}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"} {"type":"step/end","seq":237,"time":1785013634225,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":238,"time":1785013634225,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 8701c6fb19..ccbf531e96 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "复制": - img @@ -12,6 +11,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/session.jsonl b/apps/web/tests/snapshots/cordis-tool-round/session.jsonl index ab6c86089d..558f194795 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/session.jsonl +++ b/apps/web/tests/snapshots/cordis-tool-round/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785157562825,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785157562881,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"27e1fc72-20d0-4875-bf5b-d34750441f30"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785157562883,"data":{"title":"Use only Cordis tools. First","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785157562937,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -12,9 +12,9 @@ {"type":"assistant/chunk","seq":100,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}}} {"type":"assistant/chunk","seq":101,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}}}} {"type":"assistant/chunk","seq":102,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"} +{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a3b56157-c6fd-43f8-bc8a-0e2a0c99a8b5"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"} {"type":"tool/call","seq":104,"time":1785157565496,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}} -{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false},"sourceEventSeqs":[104],"surfaceOp":"append"} +{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_KZk918WtlKan9pHMULIT8794"},"content":[{"type":"tool-result","toolCallId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false}],"role":"user","id":"95ae124e-f065-4aab-a9c5-bd33c7eafff1"}},"sourceEventSeqs":[104],"surfaceOp":"append"} {"type":"step/end","seq":106,"time":1785157565503,"data":{"turn":1,"step":1}} {"type":"step/start","seq":107,"time":1785157565503,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":108,"time":1785157566524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -25,9 +25,9 @@ {"type":"assistant/chunk","seq":157,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}}} {"type":"assistant/chunk","seq":158,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}}}} {"type":"assistant/chunk","seq":159,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"} +{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b90af675-f604-4fb4-9c27-89c91b27f4ce"}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"} {"type":"tool/call","seq":161,"time":1785157567043,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}} -{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[161],"surfaceOp":"append"} +{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361"},"content":[{"type":"tool-result","toolCallId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"3148fc0a-f511-4b72-abd5-d184e974cd49"}},"sourceEventSeqs":[161],"surfaceOp":"append"} {"type":"step/end","seq":163,"time":1785157567050,"data":{"turn":1,"step":2}} {"type":"step/start","seq":164,"time":1785157567050,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":165,"time":1785157567835,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -38,9 +38,9 @@ {"type":"assistant/chunk","seq":204,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":205,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}}}} {"type":"assistant/chunk","seq":206,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"} +{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdc5b840-4302-486a-8065-fa59bf13f2d9"}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"} {"type":"tool/call","seq":208,"time":1785157568280,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[208],"surfaceOp":"append"} +{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_e38S6zeYdZGvbhecUCil6659"},"content":[{"type":"tool-result","toolCallId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"758a7b27-56e3-4997-8c4d-effc699cc5e6"}},"sourceEventSeqs":[208],"surfaceOp":"append"} {"type":"step/end","seq":210,"time":1785157568282,"data":{"turn":1,"step":3}} {"type":"step/start","seq":211,"time":1785157568282,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":212,"time":1785157569185,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -51,6 +51,6 @@ {"type":"assistant/chunk","seq":244,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CORDIS_UI_DONE"}}}} {"type":"assistant/chunk","seq":245,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}}}} {"type":"assistant/chunk","seq":246,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"} +{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"71540121-7bf6-410e-975c-f5ea99d8175a"}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"} {"type":"step/end","seq":248,"time":1785157569554,"data":{"turn":1,"step":4}} {"type":"turn/end","seq":249,"time":1785157569554,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 77038546c0..5e1a148f9e 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "复制": - img @@ -12,6 +11,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to:": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl index 53a75267e5..202b73f83a 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl +++ b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"2c594e5a-bbcc-4c64-b5ee-8e84eb5dd949"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -12,9 +12,9 @@ {"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}} {"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}} {"type":"assistant/chunk","seq":54,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"} +{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"e5d8d5fb-f555-4fda-948f-9ab178145929"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"} {"type":"tool/call","seq":56,"time":1784973851499,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}} -{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_BYXlxjFaalMg95YVqEeF2495"},"content":[{"type":"tool-result","toolCallId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false}],"role":"user","id":"215febf9-ff3c-42b8-93b7-27ce1505c1c2"}},"sourceEventSeqs":[56],"surfaceOp":"append"} {"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}} {"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -26,6 +26,6 @@ {"type":"assistant/chunk","seq":88,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":89,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}}}} {"type":"assistant/chunk","seq":90,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"} +{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"61e83a98-18a9-4f1f-9151-c8783cc39901"}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"} {"type":"step/end","seq":92,"time":1784973852461,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":93,"time":1784973852462,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 7ea4c95e2b..d0810ea315 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "复制": - img @@ -12,6 +11,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 17f79759ca..6d601aad87 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "复制": - img @@ -12,6 +11,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to reply with a single word. Let me comply.": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl b/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl index 4d7caa325d..a168f82d9e 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl +++ b/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"ee28d2ee-08b2-4ed7-a1e9-84865f55e2af"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -12,6 +12,6 @@ {"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}} {"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}} {"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"} +{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2101eadc-3475-4c77-ae0a-0107b12c34bc"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"} {"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 665221e6b4..a141ad147c 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "复制": - img @@ -12,6 +11,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - paragraph: partial - text: 已停止 - button "复制": diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 27b4a6c1c1..7fd444dc9d 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "复制": - img @@ -12,6 +11,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 9e051feea3..d857b6663c 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "复制": - img @@ -12,6 +11,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/session.jsonl b/apps/web/tests/snapshots/live-interactions/session.jsonl index e002ec48ee..91b0c730a8 100644 --- a/apps/web/tests/snapshots/live-interactions/session.jsonl +++ b/apps/web/tests/snapshots/live-interactions/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784998084441,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1784998084454,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"dc9a43b2-63a3-49bc-97e9-9dfb6465f6c1"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784998084457,"data":{"title":"Reply with a one-sentence description","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1784998084519,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1784998084520,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -12,6 +12,6 @@ {"type":"assistant/chunk","seq":86,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}}}} {"type":"assistant/chunk","seq":87,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}}}} {"type":"assistant/chunk","seq":88,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} +{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3396618c-56a2-4f97-a9c7-2a3cbb16a3c8"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} {"type":"step/end","seq":90,"time":1784998085820,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":91,"time":1784998085821,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index c8c61bae5e..bd833b6c3c 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "复制": - img diff --git a/apps/web/tests/snapshots/navigation-panes/seed.jsonl b/apps/web/tests/snapshots/navigation-panes/seed.jsonl index 72df45daac..3623cd749a 100644 --- a/apps/web/tests/snapshots/navigation-panes/seed.jsonl +++ b/apps/web/tests/snapshots/navigation-panes/seed.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785011380476,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785011380489,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"7dcfe547-5555-4e5d-b2a0-3f9d2e0836a9"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785011380492,"data":{"title":"NavScenario: first run bash to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785011380549,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785011380550,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -18,13 +18,13 @@ {"type":"assistant/chunk","seq":130,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":3,"block":{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}}} {"type":"assistant/chunk","seq":131,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}}}} {"type":"assistant/chunk","seq":132,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"} +{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"83b27ad0-4286-478f-92ef-3bc82bf0589b"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"} {"type":"tool/call","seq":134,"time":1785011382092,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"}} -{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false},"sourceEventSeqs":[134],"surfaceOp":"append"} +{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_kFKHaEXcTYEex0iDZw0C2432"},"content":[{"type":"tool-result","toolCallId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false}],"role":"user","id":"d6e29f9e-8177-4f12-b1b6-2eda4c8f5c31"}},"sourceEventSeqs":[134],"surfaceOp":"append"} {"type":"tool/call","seq":136,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"}} {"type":"tool/call","seq":137,"time":1785011382106,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}} -{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"{{cwd}}/workspace/nav-a.md\nfile\n\n1: # alpha nav\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[136],"surfaceOp":"append"} -{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"{{cwd}}/workspace/nav-b.md\nfile\n\n1: # beta nav\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[137],"surfaceOp":"append"} +{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212"},"content":[{"type":"tool-result","toolCallId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"{{cwd}}/workspace/nav-a.md\nfile\n\n1: # alpha nav\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"efca7baf-cbb8-462f-ad75-580357b58676"}},"sourceEventSeqs":[136],"surfaceOp":"append"} +{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224"},"content":[{"type":"tool-result","toolCallId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"{{cwd}}/workspace/nav-b.md\nfile\n\n1: # beta nav\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"e1c70190-9e68-429d-963d-8a30ab779ddd"}},"sourceEventSeqs":[137],"surfaceOp":"append"} {"type":"step/end","seq":140,"time":1785011382117,"data":{"turn":1,"step":1}} {"type":"step/start","seq":141,"time":1785011382118,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":142,"time":1785011382673,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -35,11 +35,11 @@ {"type":"assistant/chunk","seq":200,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST_DONE"}}}} {"type":"assistant/chunk","seq":201,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}}}} {"type":"assistant/chunk","seq":202,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"} +{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0115844e-fac0-4985-9ba7-dcf1dde63b83"}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"} {"type":"step/end","seq":204,"time":1785011383091,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":205,"time":1785011383092,"data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"turn/start","seq":206,"time":1785011383106,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1d7a8563-4ae6-49f3-9a65-41f3df300a75"},"surfaceOp":"append"} {"type":"step/start","seq":208,"time":1785011383107,"data":{"turn":2,"step":1}} {"type":"assistant/chunk","seq":209,"time":1785011383497,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":210,"time0":1785011383497,"data":{"turn":2,"step":1,"index":0,"dt":[125,23,1,0,0,88,0,0,5,0,1,0,0,7,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," specific"," format","."," Let"," me"," do"," that","."]}} @@ -49,6 +49,6 @@ {"type":"assistant/chunk","seq":247,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}}}} {"type":"assistant/chunk","seq":248,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}}}} {"type":"assistant/chunk","seq":249,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"} +{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"70b38db9-38a2-4f9f-8094-1bd799f5f270"}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"} {"type":"step/end","seq":251,"time":1785011383904,"data":{"turn":2,"step":1}} {"type":"turn/end","seq":252,"time":1785011383904,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 80d6f161ca..0bdf87d67a 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -1 +1,53 @@ -- text: "Turn 1 Message {{duration}} #1 User NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. +{{duration}} Step 1 {{duration}} bash read×2 #2 Tool bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} +{{duration}} #3 Tool read · {\"file_path\": \"nav-a.md\"} +{{duration}} #4 Tool read · {\"file_path\": \"nav-b.md\"} +{{duration}} Step 2 {{duration}} #5 Message FIRST_DONE 349 56 51 +{{duration}} Turn 2 Message {{duration}} #6 User Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. +{{duration}} Step 1 {{duration}} #7 Message ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ``` 141 36 16 +{{duration}}" +- toolbar "Trajectory toolbar": + - button "Use actual duration": Duration + - button "Collapse turns": Turns + - button "Collapse calls": Calls + - img + - searchbox "Search trajectory" +- region "Trajectory timeline" +- table: + - rowgroup: + - row "SYSTEM, Initial System Prompt": + - cell "SYSTEM" + - cell "Initial System Prompt" + - 'row "USER, NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."': + - cell "Turn 1 USER" + - 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."' + - 'row "Request 1, ASSISTANT, The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."': + - 'cell "Request #1 ASSISTANT"': + - 'button "Request #1"' + - text: ASSISTANT + - 'cell "The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."' + - 'row "TOOL, bash {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}" [selected]': + - cell "TOOL" + - 'cell "bash{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} → NAVIGATION_OK"' + - 'row "TOOL, read {\"file_path\": \"nav-a.md\"}"': + - cell "TOOL" + - 'cell "read{\"file_path\": \"nav-a.md\"} → {{cwd}}/nav-a.md file 1: # alpha nav (End of file - total 1 lines) "' + - 'row "TOOL, read {\"file_path\": \"nav-b.md\"}"': + - cell "TOOL" + - 'cell "read{\"file_path\": \"nav-b.md\"} → {{cwd}}/nav-b.md file 1: # beta nav (End of file - total 1 lines) "' + - row "Request 2, ASSISTANT, FIRST_DONE": + - 'cell "Request #2 ASSISTANT"': + - 'button "Request #2"' + - text: ASSISTANT + - cell "FIRST_DONE" + - 'row "USER, Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."': + - cell "Turn 2 USER" + - 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."' + - row "Request 3, ASSISTANT, Navigation Summary alpha nav beta nav echo WATERFALL": + - 'cell "Request #3 ASSISTANT"': + - 'button "Request #3"' + - text: ASSISTANT + - cell "Navigation Summary alpha nav beta nav echo WATERFALL" +- complementary "Event details": + - separator "Resize event details" + - text: TOOL Turn 1 · Step 1 + - button "Close details" + - tablist "Event details": + - tab "Summary" + - tab "Payload" + - tab "Result" [selected] + - tab "Schema" + - tab "Timing" + - tabpanel "Result": NAVIGATION_OK diff --git a/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md b/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md deleted file mode 100644 index 6c5ab1a046..0000000000 --- a/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md +++ /dev/null @@ -1 +0,0 @@ -- text: 3 turns · 3 steps · 3 tool calls turn 0 turn 1 turn 2 diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 91ff2cdf88..bd03429898 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -4,18 +4,23 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" -- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop." +- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" - button "复制": - img - button "在新对话中分支": - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} - button: - img - img @@ -25,7 +30,11 @@ - img - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. - paragraph: DONE -- text: cache hit 95% · 8,769 tokens · 1 turns · 2 steps +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl index a98f2a92f2..b13a84e22c 100644 --- a/apps/web/tests/snapshots/question-composer/session.jsonl +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"6deba879-8787-4853-a5f2-0d108a08eb2d"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -12,9 +12,9 @@ {"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} {"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} {"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} +{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdb1676c-e781-41ee-8f28-a3595371d729"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} {"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} -{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} +{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Cijldc88LYmVPCXYUsRq1617"},"content":[{"type":"tool-result","toolCallId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false}],"role":"user","id":"c69ef39f-6f62-439f-b3f8-e8d10fba572f"}},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}} {"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -26,6 +26,6 @@ {"type":"assistant/chunk","seq":160,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":161,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}}}} {"type":"assistant/chunk","seq":162,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"} +{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b5f572cb-f2a7-4fa0-8097-a5551700a920"}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"} {"type":"step/end","seq":164,"time":1785150170858,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":165,"time":1785150170858,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/seeded-history/seed.jsonl b/apps/web/tests/snapshots/seeded-history/seed.jsonl index abf7a61162..bf69bcf152 100644 --- a/apps/web/tests/snapshots/seeded-history/seed.jsonl +++ b/apps/web/tests/snapshots/seeded-history/seed.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"38f072be-5254-4cb7-b76e-d612b2ae3b3a"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -15,11 +15,11 @@ {"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}} {"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}} {"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"ec076738-f75d-4525-ba99-c8fc16acf955"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}} {"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}} -{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"} -{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[66],"surfaceOp":"append"} +{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OsndvlcKnCcUmae7QXal8633"},"content":[{"type":"tool-result","toolCallId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ea2294eb-8652-4492-8a08-9c24d3f8a60f"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725"},"content":[{"type":"tool-result","toolCallId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"f02b3acf-4e03-4b4d-beeb-1a564c9c6d61"}},"sourceEventSeqs":[66],"surfaceOp":"append"} {"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}} {"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -31,6 +31,6 @@ {"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}} {"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"} +{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"07627a5e-4cb2-47ef-9b50-88893aac7406"}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"} {"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index db71bd6696..c65bf7e712 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "复制": - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 87ed907abd..b70aa38d2c 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "复制": - img @@ -12,6 +11,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img diff --git a/apps/web/tests/snapshots/steering/session.jsonl b/apps/web/tests/snapshots/steering/session.jsonl index 4015fa4ab8..eb21251997 100644 --- a/apps/web/tests/snapshots/steering/session.jsonl +++ b/apps/web/tests/snapshots/steering/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785004180013,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785004180030,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785004180030,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785004180030,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"eb77fa3d-5d60-4028-a592-e1f07a288f35"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785004180033,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785004180105,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785004180106,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} @@ -12,10 +12,10 @@ {"type":"assistant/chunk","seq":85,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}}} {"type":"assistant/chunk","seq":86,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}}}} {"type":"assistant/chunk","seq":87,"time":1785004181402,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} +{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"ac0cfafd-795f-4bc3-9dc9-a8df33538a38"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} {"type":"tool/call","seq":89,"time":1785004181407,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}} -{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} -{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sAvjivLShvnWVk0sPQPV7661"},"content":[{"type":"tool-result","toolCallId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false}],"role":"user","id":"9c2175f8-3cd4-4d6c-9320-498d25f83342"}},"sourceEventSeqs":[89],"surfaceOp":"append"} +{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"message":{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"291e02ad-cf8a-459f-a388-f81ed334e629"}},"surfaceOp":"append"} {"type":"step/end","seq":92,"time":1785004181870,"data":{"turn":1,"step":1}} {"type":"step/start","seq":93,"time":1785004181870,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":94,"time":1785004182322,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -26,6 +26,6 @@ {"type":"assistant/chunk","seq":137,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Great, let's move forward. BANANA!"}}}} {"type":"assistant/chunk","seq":138,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}}}} {"type":"assistant/chunk","seq":139,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":140,"time":1785004182894,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."},{"type":"text","text":"Great, let's move forward. BANANA!"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139],"surfaceOp":"append"} +{"type":"assistant/message","seq":140,"time":1785004182894,"data":{"turn":1,"step":2,"usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."},{"type":"text","text":"Great, let's move forward. BANANA!"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b49cca3a-fa40-4f45-91c9-e7c3f15fa233"}},"sourceEventSeqs":[94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139],"surfaceOp":"append"} {"type":"step/end","seq":141,"time":1785004182895,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":142,"time":1785004182895,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 7a2f9ae0e6..790bd022f2 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -4,7 +4,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "复制": - img @@ -12,6 +11,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 94b6bd4c5d..89783b5d16 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md -compaction.md: 3ba5edd96c509e064ac7033b175b7ddd3c972452 -compaction.zh.md: d082b0d0545802500278e96ca41a273bce275f53 +compaction.md: 911b71d00fa4b42e9cdfa67f67d4e9b29e354a4a +compaction.zh.md: 643a116ff2edbbb53d300b4f5ff0ad36d401130b diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 3ba5edd96c..911b71d00f 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -13,7 +13,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de | Event | Payload | Role | |---|---|---| | `compact/start` | `{ turn }` | acquires the log-recorded lock | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens? }` | provenance: the summary blocks, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) | +| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) | | `compact/end` | `{ turn, error? }` | releases the lock (`error` set when summarization threw) | The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished. @@ -22,7 +22,7 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b ## `CompactionResult` -What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count. +What a successful compaction returns to its caller: the bookkeeping-event seqs, safe summary projection, shadowed range and seqs, and estimated token count. ```ts type-equiv /** Result of a successful compaction operation. */ diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index d082b0d054..643a116ff2 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -13,7 +13,7 @@ | 事件 | 载荷 | 作用 | |---|---|---| | `compact/start` | `{ turn }` | 获取日志记录的锁 | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens? }` | provenance:摘要块、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | +| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | | `compact/end` | `{ turn, error? }` | 释放锁(摘要调用抛出异常时设置 `error`) | 锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。 @@ -22,7 +22,7 @@ ## `CompactionResult` -成功压缩向调用方返回:记账事件 seq、原始摘要、被遮蔽的范围与 seq,以及估算 token 数。 +成功压缩向调用方返回:记账事件 seq、安全摘要投影、被遮蔽的范围与 seq,以及估算 token 数。 ```ts type-equiv /** Result of a successful compaction operation. */ diff --git a/docs/module-graph.md b/docs/module-graph.md index dc31bfc5b1..233c7b49fc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -265,7 +265,6 @@ flowchart TD pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_question --> pkg_invariants pkg_client_ui_slots --> pkg_invariants - pkg_client_ui_trajectory --> pkg_invariants pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants pkg_code_runtime --> pkg_invariants @@ -305,6 +304,8 @@ flowchart TD pkg_client_ui_slash --> pkg_client_runtime pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_invariants pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots @@ -989,7 +990,6 @@ flowchart TD | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | @@ -1008,6 +1008,7 @@ flowchart TD | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a9bb298c7c..dea3c4816b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -213,7 +213,7 @@ Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/in 'compact/end': { turn: number; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:40`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:44`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -235,6 +235,8 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact */ 'compact/summary': { summary: ContentBlock[] + /** Complete provider output before the backend's safe summary projection. */ + rawOutput?: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] shadowedTokenCount: number @@ -249,10 +251,12 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact model: string /** The generation cap the summarize call sent, when one applied. */ maxTokens?: number + /** Provider-reported token usage for the summarization request, when emitted. */ + usage?: TokenUsage } ``` -Types: [ContentBlock](core-data-structures/core.md) +Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 71a0b07ecd..157083018a 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: eeeb813d6d0ddddf9c5c718a9ede65b3e222c220 -README.zh.md: 5531cd4724df2c63a9a5c6d1923b52c1f578db10 +README.md: b51cc0276d8635ea9faa506e30246a107c1c1418 +README.zh.md: 4b2248d875ae37f1b848c51a0009d2497c6b3e61 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index eeeb813d6d..b51cc0276d 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 5531cd4724..4b2248d875 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录末尾的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。 ## Workspace 与 Session 列表 diff --git a/packages/client/runtime/src/client/contract/session-history.ts b/packages/client/runtime/src/client/contract/session-history.ts new file mode 100644 index 0000000000..2b6679fd05 --- /dev/null +++ b/packages/client/runtime/src/client/contract/session-history.ts @@ -0,0 +1,35 @@ +import type { + RpcError, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' +import type { SessionHistoryInspection } from '../sessions/history.ts' +import type { ObservableSnapshot } from './store.ts' + +/** Observable state of one independently loaded session history ledger. */ +export interface SessionHistorySnapshot { + state: 'cold' | 'loading' | 'ready' | 'error' + error: RpcError | null + hasMore: boolean + inspection: SessionHistoryInspection +} + +/** Read-only history source addressed by session id. */ +export interface SessionHistoryFace + extends ObservableSnapshot { + readonly sessionId: SessionId + /** + * Load the tail and exhaust every available older page. + * @param signal - Consumer lifetime; abort is observed between page requests. + * @returns When the available ledger is complete or stops advancing. + */ + loadAll(signal?: AbortSignal): Promise +} + +/** Runtime service resolving independent history sources. */ +export interface ISessionHistory { + /** + * Resolve the identity-stable source for a session. + * @param sessionId - Host session identity. + * @returns The source owned outside Session and SessionManager. + */ + source(sessionId: SessionId): SessionHistoryFace +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 3b8e02b5a9..f359eb1eac 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -5,6 +5,7 @@ import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek- import { SlotsService } from './slots.ts' import { SessionsService } from './sessions/service.ts' import type { SessionListState } from './sessions/service.ts' +import { SessionHistoryService } from './session-history/service.ts' import { WorkspacesService } from './workspaces/service.ts' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' import type { UseProjection } from './sessions/projection-store.ts' @@ -12,6 +13,7 @@ import type { UseProjection } from './sessions/projection-store.ts' export { SlotsService } from './slots.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' +export { SessionHistoryService } from './session-history/service.ts' // The provide channel is shared with the client test runtime (one // materialization/projection implementation; no test-side mirror to drift). export { SessionProvideChannel } from './sessions/provide.ts' @@ -21,6 +23,9 @@ export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' +export type { + ISessionHistory, SessionHistoryFace, SessionHistorySnapshot, +} from './contract/session-history.ts' export type { ISessions } from './contract/sessions.ts' export type { IWorkspaces } from './contract/workspaces.ts' export type { @@ -38,10 +43,19 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode, + AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, + AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' +export type { + ConversationContext, ConversationContextOriginKind, +} from './sessions/conversation-context.ts' +export type { + ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, +} from './sessions/request-inspection.ts' +export type { ConversationHistoryProjection } from './session-history/history-fold.ts' +export type { SessionHistoryInspection } from './sessions/history.ts' export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' // Projection value store (session-projection RFC, push model): host-computed @@ -120,6 +134,8 @@ declare module 'cordis' { slots: import('./slots.ts').SlotsService /** The outward face only; the concrete service stays inside the runtime. */ sessions: import('./contract/sessions.ts').ISessions + /** Read-only history sources isolated from Chat sessions and workspace state. */ + sessionHistory: import('./contract/session-history.ts').ISessionHistory /** The outward face only; the concrete service stays inside the runtime. */ workspaces: import('./contract/workspaces.ts').IWorkspaces } @@ -135,30 +151,55 @@ export function apply(ctx: Context): void { ctx.plugin(SlotsService) const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionsService(ctx, connection.api) + const sessionHistory = new SessionHistoryService(ctx, connection.api) const workspaces = new WorkspacesService(ctx, connection.api, sessions) ctx.effect( () => workspaces.startInitialSelection(), 'runtime: initial Workspace selection', ) const loop = connection.start({ - onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) }, + onMuxEnvelope: (envelope) => { + sessions.handleMuxEnvelope(envelope) + try { + sessionHistory.handleMuxEnvelope(envelope) + } catch (error) { + console.error('[web-runtime] history frame routing failed:', error) + } + }, onHostEnvelope: (envelope) => { sessions.handleHostEnvelope(envelope) workspaces.handleHostEnvelope(envelope) // Typed-event bridge: the session layer ignores registry frames (no // session routing); consumers (command directory caches) subscribe on ctx. if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed') + try { + sessionHistory.handleHostEnvelope(envelope) + } catch (error) { + console.error('[web-runtime] history host-frame routing failed:', error) + } }, onConnected: () => { sessions.handleConnected() workspaces.handleConnected() ctx.emit('connection/reset') + try { + sessionHistory.handleConnected() + } catch (error) { + console.error('[web-runtime] history reconnect failed:', error) + } }, onStateChange: (state) => { // Generation death fires before any next-generation frame can arrive // (reconnect replays flow from stream open, ahead of onConnected): // the only safe moment to drop generation-scoped interaction state. - if (state === 'reconnecting') sessions.handleDisconnected() + if (state === 'reconnecting') { + sessions.handleDisconnected() + try { + sessionHistory.handleDisconnected() + } catch (error) { + console.error('[web-runtime] history disconnect failed:', error) + } + } }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts new file mode 100644 index 0000000000..e7f9922425 --- /dev/null +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -0,0 +1,472 @@ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { + SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, +} from '@deepseek-ai/dsh-session/surface' +import type { + HistoryEntry, ToolCallView, ToolResultView, +} from '@deepseek-ai/dsh-client-connection/client' +import type { + AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode, + PartialAssistant, RunningToolCall, +} from '../sessions/conversation.ts' +import { toAssistantBlocks } from '../sessions/conversation.ts' +import type { + ConversationContext, ConversationContextOriginKind, +} from '../sessions/conversation-context.ts' +import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts' +import { PartialAccumulator } from '../sessions/partial.ts' + +interface CallIndexEntry { + name: string + argsRaw: string + time: number + callView: ToolCallView | null +} + +interface FoldedContext { + generation: number + nodes: readonly number[] + originSeq?: number +} + +interface AssistantStepMetadata { + stepStartTime: number | null + firstTokenTime: number | null +} + +/** Immutable conversation projections derived only from the history source. */ +export interface ConversationHistoryProjection { + eventNodes: readonly ConversationNode[] + contexts: readonly ConversationContext[] + interruptedNodes: readonly ConversationNode[] + partial: PartialAssistant | null + runningCalls: readonly RunningToolCall[] + codeDispatches: ReadonlyMap +} + +function assistantStepKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +// Trajectory owns surface-window reconstruction so its immutable ledger does +// not depend on Chat's live fold adapter or Session's mutable state. +/* jscpd:ignore-start */ +function paddingEvent(seq: number): SessionEvent { + return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent +} + +function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { + if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false + return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq +} +/* jscpd:ignore-end */ + +function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { + if (event?.type !== 'user/message') return 'rewrite' + const source = event.data.source + if (typeof source === 'object' && 'kind' in source && 'plugin' in source) { + if (source.plugin === 'compact') return 'compaction' + if (source.plugin === 'rewind') return 'rewind' + } + return 'rewrite' +} + +function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} + +function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] { + const replay: SessionEvent[] = [] + const surface = new SurfaceManager(replay) + const contexts: FoldedContext[] = [] + let generation = 0 + let originSeq: number | undefined + for (const event of events) { + if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { + contexts.push({ + generation, + nodes: [...surface.nodes], + ...(originSeq === undefined ? {} : { originSeq }), + }) + generation++ + originSeq = event.seq + } + replay.push(event) + } + contexts.push({ + generation, + nodes: [...surface.nodes], + ...(originSeq === undefined ? {} : { originSeq }), + }) + return contexts +} + +// History projection owns its node mapping so Chat's live adapter remains free +// of inspection metadata and lifecycle coupling. +/* jscpd:ignore-start */ +function materializeNode( + event: SessionEvent, + callIndex: ReadonlyMap, + resultView: ToolResultView | null, + assistantTiming: AssistantTiming | undefined, + requestConfig: AssistantRequestConfig | undefined, +): ConversationNode { + switch (event.type) { + case 'user/message': + if (event.data.source.kind !== 'user') { + return { + kind: 'context', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, + } + } + return { + kind: 'user', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, + } + case 'assistant/message': + return { + kind: 'assistant', seq: event.seq, time: event.time, + turn: event.data.turn, step: event.data.step, + blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, + provenance: { + provider: event.data.message.source.provider, + model: event.data.message.source.model, + }, + ...(requestConfig === undefined ? {} : { requestConfig }), + ...(assistantTiming === undefined ? {} : { timing: assistantTiming }), + } + case 'steering/message': + return { + kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + content: event.data.message.content, source: event.data.message.source, + } + case 'tool/result': { + const result = event.data.message.content[0] + const callId = String(event.data.message.source.callId) + const call = callIndex.get(callId) + return { + kind: 'tool-result', seq: event.seq, time: event.time, + callId, + call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw }, + callTime: call?.time ?? null, + content: result.content, isError: result.isError === true, + ...(event.data.error === undefined ? {} : { error: event.data.error }), + meta: event.data.meta, + callView: call?.callView ?? null, + resultView, + } + } + default: + return { + kind: 'unknown', seq: event.seq, time: event.time, + type: event.type, data: (event as { data?: unknown }).data, + } + } +} +/* jscpd:ignore-end */ + +function projectTransient(entries: readonly HistoryEntry[]): Pick< + ConversationHistoryProjection, + 'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches' +> { + let partial: PartialAccumulator | null = null + const openCalls = new Map() + const interruptedNodes: ConversationNode[] = [] + const codeDispatches = new Map() + + for (const entry of entries) { + const { event } = entry + if ((event.type as string) === 'tool/code-dispatch-start') { + const data = event.data as unknown as { + parentCallId: string + subCallId: string + name: string + arguments: unknown + } + const siblings = codeDispatches.get(data.parentCallId) ?? [] + // The independent replay emits the same public running-call shape as + // Chat without reading or mutating Session's live index. + /* jscpd:ignore-start */ + codeDispatches.set(data.parentCallId, [...siblings, { + callId: data.subCallId, + name: data.name, + argsRaw: JSON.stringify(data.arguments), + turn: 0, + step: 0, + time: event.time, + callView: null, + }]) + /* jscpd:ignore-end */ + continue + } + if ((event.type as string) === 'tool/code-dispatch') { + const data = event.data as unknown as { + parentCallId: string + subCallId: string + name: string + arguments: unknown + isError: boolean + content: ContentBlock[] + } + const siblings = codeDispatches.get(data.parentCallId) ?? [] + const at = siblings.findIndex(sub => sub.callId === data.subCallId) + const started = at === -1 ? undefined : siblings[at] + // History independently reproduces the public settled-call shape instead + // of consuming Session's live code-dispatch projection. + /* jscpd:ignore-start */ + const settled: CodeSubCall = { + kind: 'tool-result', seq: event.seq, time: event.time, + callId: data.subCallId, + call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, + callTime: started?.time ?? null, + content: data.content, + isError: data.isError, + callView: null, + resultView: null, + } + codeDispatches.set( + data.parentCallId, + at === -1 + ? [...siblings, settled] + : siblings.map((sub, index) => index === at ? settled : sub), + ) + /* jscpd:ignore-end */ + continue + } + switch (event.type) { + case 'assistant/chunk': { + const { turn, step, chunk } = event.data + if (partial === null || partial.turn !== turn || partial.step !== step) { + partial = new PartialAccumulator(turn, step) + } + partial.push(chunk) + break + } + case 'assistant/message': + if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null + break + case 'tool/call': + // History reconstructs its own in-flight index; this intentionally + // mirrors the published Chat node shape, not Chat's mutable state. + /* jscpd:ignore-start */ + openCalls.set(String(event.data.callId), { + callId: String(event.data.callId), + name: event.data.name, + argsRaw: event.data.arguments, + turn: event.data.turn, + step: event.data.step, + time: event.time, + callView: entry.view?.for === 'call' ? entry.view.view : null, + }) + /* jscpd:ignore-end */ + break + case 'tool/result': + openCalls.delete(String(event.data.message.source.callId)) + break + case 'turn/end': { + if (partial !== null && partial.turn === event.data.turn) { + const { blocks } = partial.toPartial() + const visible = blocks.some(block => + block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true) + if (visible) { + interruptedNodes.push({ + kind: 'assistant', seq: event.seq - 0.9, time: event.time, + turn: partial.turn, step: partial.step, blocks, interrupted: true, + }) + } + partial = null + } + let callOffset = 0 + for (const [callId, call] of openCalls) { + if (call.turn !== event.data.turn) continue + openCalls.delete(callId) + // Interrupted terminal nodes are reconstructed independently so a + // Trajectory replay cannot observe Session's frozen-node lifecycle. + /* jscpd:ignore-start */ + interruptedNodes.push({ + kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, + time: event.time, + callId, + call: { name: call.name, argsRaw: call.argsRaw }, + callTime: call.time, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: call.callView, + resultView: null, + }) + /* jscpd:ignore-end */ + } + break + } + default: + break + } + } + + return { + interruptedNodes, + partial: partial?.toPartial() ?? null, + runningCalls: [...openCalls.values()], + codeDispatches, + } +} + +/** + * Project one immutable history ledger without reading or mutating Chat state. + * @param entries - Contiguous history entries in sequence order. + * @returns Event order, context lineage, and transient tail state. + */ +export function projectConversationHistory( + entries: readonly HistoryEntry[], +): ConversationHistoryProjection { + const events = entries.map(entry => entry.event) + const baseSeq = events[0]?.seq ?? 0 + const padded = [ + ...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)), + ...events, + ] + const callIndex = new Map() + const resultViews = new Map() + const assistantSteps = new Map() + const assistantTimings = new Map() + const assistantRequestConfigs = new Map() + const promptsByContext = new Map() + let activeRequestConfig: AssistantRequestConfig | undefined + let activePrompt: ConversationPromptSnapshot | undefined + let contextGeneration = 0 + + for (const [index, event] of events.entries()) { + const view = entries[index]?.view + if (event.type === 'tool/call') { + callIndex.set(String(event.data.callId), { + name: event.data.name, + argsRaw: event.data.arguments, + time: event.time, + callView: view?.for === 'call' ? view.view : null, + }) + } else if (event.type === 'tool/result' && view?.for === 'result') { + resultViews.set(event.seq, view.view) + } + if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { + contextGeneration++ + if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt) + } + if (event.type === 'request/header') { + activeRequestConfig = event.data.header.config + activePrompt = { + config: event.data.header.config, + system: event.data.header.system ?? '', + tools: event.data.header.tools ?? [], + } + promptsByContext.set(contextGeneration, activePrompt) + } else if (event.type === 'step/start') { + assistantSteps.set( + assistantStepKey(event.data.turn, event.data.step), + { stepStartTime: event.time, firstTokenTime: null }, + ) + } else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) { + const key = assistantStepKey(event.data.turn, event.data.step) + const current = assistantSteps.get(key) ?? { + stepStartTime: null, + firstTokenTime: null, + } + if (current.firstTokenTime === null) { + assistantSteps.set(key, { ...current, firstTokenTime: event.time }) + } + } else if (event.type === 'assistant/message') { + assistantTimings.set( + event.seq, + { + ...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? { + stepStartTime: null, + firstTokenTime: null, + }), + completedTime: event.time, + }, + ) + if (activeRequestConfig !== undefined) { + assistantRequestConfigs.set(event.seq, activeRequestConfig) + } + } + } + + const nodeCache = new Map() + const materialize = (seq: number): ConversationNode | undefined => { + const cached = nodeCache.get(seq) + if (cached !== undefined) return cached + const event = padded[seq] + if (event === undefined || !isSurfaceEligibleType(event.type)) return + const node = materializeNode( + event, + callIndex, + resultViews.get(seq) ?? null, + assistantTimings.get(seq), + assistantRequestConfigs.get(seq), + ) + nodeCache.set(seq, node) + return node + } + const eventNodes = events.flatMap((event) => { + const node = materialize(event.seq) + return node === undefined ? [] : [node] + }) + + let contexts: readonly ConversationContext[] + if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) { + contexts = [{ + id: 0, + ...(activePrompt === undefined ? {} : { prompt: activePrompt }), + nodes: eventNodes, + }] + } else { + try { + contexts = foldContexts(padded).map((context): ConversationContext => { + const nodes = context.nodes.flatMap((seq) => { + const node = materialize(seq) + return node === undefined ? [] : [node] + }) + const prompt = promptsByContext.get(context.generation) + if (context.originSeq === undefined) { + return { + id: context.generation, + ...(prompt === undefined ? {} : { prompt }), + nodes, + } + } + const originEvent = padded[context.originSeq] + return { + id: context.generation, + parentId: context.generation - 1, + origin: contextOriginKind(originEvent), + originSeq: context.originSeq, + ...(originEvent === undefined ? {} : { createdAt: originEvent.time }), + ...(prompt === undefined ? {} : { prompt }), + nodes, + } + }) + } catch (error) { + console.error('[web-runtime] history surface fold failed, using event order:', error) + contexts = [{ + id: 0, + ...(activePrompt === undefined ? {} : { prompt: activePrompt }), + nodes: eventNodes, + }] + } + } + + return { + eventNodes, + contexts, + ...projectTransient(entries), + } +} diff --git a/packages/client/runtime/src/client/session-history/service.ts b/packages/client/runtime/src/client/session-history/service.ts new file mode 100644 index 0000000000..b5aba32bce --- /dev/null +++ b/packages/client/runtime/src/client/session-history/service.ts @@ -0,0 +1,66 @@ +import type { Context } from 'cordis' +import type { + HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' +import type { + ISessionHistory, SessionHistoryFace, +} from '../contract/session-history.ts' +import { SessionHistorySource } from './source.ts' + +/** Root registry and frame router for independent inspection histories. */ +export class SessionHistoryService implements ISessionHistory { + private readonly sources = new Map() + + /** + * @param ctx - Client root context. + * @param api - Shared wire client. + */ + constructor(ctx: Context, private readonly api: IApiClient) { + ctx.reflect.provide('sessionHistory', this, undefined) + } + + /** + * Resolve one identity-stable history source. + * @param sessionId - Host session identity. + * @returns Source independent from SessionManager. + */ + source(sessionId: SessionId): SessionHistoryFace { + let source = this.sources.get(sessionId) + if (source === undefined) { + source = new SessionHistorySource(sessionId, this.api) + this.sources.set(sessionId, source) + } + return source + } + + /** + * Route history-relevant mux frames only to an existing source. + * @param envelope - Validated mux envelope. + */ + handleMuxEnvelope(envelope: RpcRequest): void { + const frame = envelope.payload + if (frame.type === 'stream/error') return + this.sources.get(frame.sessionId)?.handleMuxFrame(frame) + } + + /** + * Drop a removed session's independent history source. + * @param envelope - Validated host envelope. + */ + handleHostEnvelope(envelope: RpcRequest): void { + const frame = envelope.payload + if (frame.type !== 'host/session-removed') return + this.sources.get(frame.sessionId)?.dispose() + this.sources.delete(frame.sessionId) + } + + /** Invalidate requests from the dead connection generation. */ + handleDisconnected(): void { + for (const source of this.sources.values()) source.handleDisconnected() + } + + /** Rebuild every previously activated source from the new generation. */ + handleConnected(): void { + for (const source of this.sources.values()) source.resync() + } +} diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts new file mode 100644 index 0000000000..8de11b9da9 --- /dev/null +++ b/packages/client/runtime/src/client/session-history/source.ts @@ -0,0 +1,352 @@ +import type { + HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' +import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { + SessionHistoryFace, SessionHistorySnapshot, +} from '../contract/session-history.ts' +import { createHistoryInspection } from '../sessions/history.ts' +import { Notifier } from '../sessions/notifier.ts' + +const HISTORY_PAGE_MESSAGES = 50 + +function isAborted(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true +} + +/** Independent raw-history owner used only by inspection consumers. */ +export class SessionHistorySource implements SessionHistoryFace { + private entries: readonly HistoryEntry[] = [] + private baseSeq = 0 + private hasMore = false + private state: SessionHistorySnapshot['state'] = 'cold' + private error: RpcError | null = null + private generation = 0 + private persistentConsumer = false + private readonly consumerSignals = new Set() + private openPromise: Promise | null = null + private olderPromise: Promise | null = null + private stitching = false + private liveBuffer: HistoryEntry[] = [] + private subscribedLastSeq: number | null = null + private inspectionCache: { + entries: readonly HistoryEntry[] + value: SessionHistorySnapshot['inspection'] + } | null = null + private snapshotCache: SessionHistorySnapshot + private readonly notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot() + }) + + /** + * @param sessionId - Host session identity. + * @param api - Shared wire client. + */ + constructor( + readonly sessionId: SessionId, + private readonly api: IApiClient, + ) { + this.snapshotCache = this.buildSnapshot() + } + + /** + * Subscribe to ledger changes. + * @param listener - Change callback. + * @returns Unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener) + } + + /** + * Read the cached ledger snapshot. + * @returns Stable snapshot until the source changes. + */ + getSnapshot(): SessionHistorySnapshot { + this.notifier.ensureFresh() + return this.snapshotCache + } + + /** + * Load the tail and exhaust all available older pages. + * @param signal - Consumer lifetime. + * @returns When paging completes, fails to advance, or is aborted. + */ + async loadAll(signal?: AbortSignal): Promise { + if (signal?.aborted === true) return + this.trackConsumer(signal) + await this.open() + while ( + !isAborted(signal) + && this.state === 'ready' + && this.hasMore + ) { + const previousBaseSeq = this.baseSeq + await this.loadOlder() + if (isAborted(signal) || this.baseSeq === previousBaseSeq) return + } + } + + /** Rebuild and page for whichever mounted consumers survive a reconnect. */ + private async loadForConsumers(): Promise { + await this.open() + while ( + this.hasConsumer() + && this.state === 'ready' + && this.hasMore + ) { + const previousBaseSeq = this.baseSeq + await this.loadOlder() + if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return + } + } + + /** + * Route a relevant mux frame without involving the Chat session. + * @param frame - Session-addressed frame. + */ + handleMuxFrame(frame: MuxFrame): void { + if (frame.type === 'session/subscribed') { + this.subscribedLastSeq = frame.lastSeq + return + } + if (frame.type !== 'session/event') return + this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) }) + } + + /** Invalidate dead-generation requests while retaining the last readable snapshot. */ + handleDisconnected(): void { + this.generation++ + this.openPromise = null + this.olderPromise = null + this.stitching = false + this.liveBuffer = [] + this.subscribedLastSeq = null + if (this.state !== 'cold') { + this.state = 'cold' + this.error = null + this.notifier.markDirty() + } + } + + /** Rebuild an activated ledger from the new connection generation. */ + resync(): void { + if (!this.hasConsumer()) return + this.generation++ + this.openPromise = null + this.olderPromise = null + this.stitching = false + this.liveBuffer = [] + this.subscribedLastSeq = null + this.entries = [] + this.baseSeq = 0 + this.hasMore = false + this.state = 'cold' + this.error = null + this.notifier.markDirty() + void this.loadForConsumers() + } + + /** Stop future refresh work after the host removes the session. */ + dispose(): void { + this.persistentConsumer = false + this.consumerSignals.clear() + this.generation++ + this.openPromise = null + this.olderPromise = null + this.liveBuffer = [] + } + + private open(): Promise { + if (this.state === 'ready') return Promise.resolve() + if (this.openPromise !== null) return this.openPromise + const generation = this.generation + const operation = this.doOpen(generation) + const settled = operation.finally(() => { + if (this.openPromise === settled) this.openPromise = null + }) + this.openPromise = settled + return settled + } + + private trackConsumer(signal: AbortSignal | undefined): void { + if (signal === undefined) { + this.persistentConsumer = true + return + } + if (this.consumerSignals.has(signal)) return + this.consumerSignals.add(signal) + signal.addEventListener('abort', () => { + this.consumerSignals.delete(signal) + }, { once: true }) + } + + private hasConsumer(): boolean { + return this.persistentConsumer || this.consumerSignals.size > 0 + } + + private async doOpen(generation: number): Promise { + this.state = 'loading' + this.error = null + this.notifier.markDirty() + try { + let { result } = await this.api.sessions.history({ + sessionId: this.sessionId, + maxMessages: HISTORY_PAGE_MESSAGES, + }) + if (generation !== this.generation) return + if (!result.ok) { + this.state = 'error' + this.error = result.error + return + } + this.installTail(result.value.events, result.value.hasMore, true) + const tailSeq = this.tailSeq() + if ( + this.subscribedLastSeq !== null + && tailSeq !== null + && this.subscribedLastSeq > tailSeq + ) { + result = (await this.api.sessions.history({ + sessionId: this.sessionId, + maxMessages: HISTORY_PAGE_MESSAGES, + })).result + if (generation !== this.generation) return + if (result.ok) this.installTail(result.value.events, result.value.hasMore, true) + } + this.state = 'ready' + } catch (error) { + if (generation !== this.generation) return + this.state = 'error' + const folded = transportError(error) + /* v8 ignore next -- transportError always returns the error branch. */ + this.error = folded.ok ? null : folded.error + } finally { + if (generation === this.generation) this.notifier.markDirty() + } + } + + private loadOlder(): Promise { + if (this.olderPromise !== null) return this.olderPromise + if (this.state !== 'ready' || !this.hasMore) return Promise.resolve() + const generation = this.generation + const operation = (async () => { + try { + const { result } = await this.api.sessions.history({ + sessionId: this.sessionId, + beforeSeq: this.baseSeq, + maxMessages: HISTORY_PAGE_MESSAGES, + }) + if (generation !== this.generation || this.state !== 'ready' || !result.ok) return + const older = result.value.events + if (older.length === 0) { + this.hasMore = result.value.hasMore + return + } + const tail = older.at(-1) + if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { + console.error( + `[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`, + ) + this.hasMore = false + return + } + this.entries = [...older, ...this.entries] + this.baseSeq = older[0]?.event.seq ?? this.baseSeq + this.hasMore = result.value.hasMore + } catch (error) { + console.error('[web-runtime] inspection history paging failed:', error) + } + })() + const settled = operation.finally(() => { + if (this.olderPromise !== settled) return + this.olderPromise = null + this.notifier.markDirty() + }) + this.olderPromise = settled + return settled + } + + private installTail( + tail: readonly HistoryEntry[], + hasMore: boolean, + replace: boolean, + ): void { + if (replace) { + this.entries = [...tail] + this.hasMore = hasMore + } else { + const firstSeq = tail[0]?.event.seq + const prefix = firstSeq === undefined + ? this.entries + : this.entries.filter(entry => entry.event.seq < firstSeq) + this.entries = [...prefix, ...tail] + } + this.baseSeq = this.entries[0]?.event.seq ?? 0 + const buffered = this.liveBuffer + this.liveBuffer = [] + for (const entry of buffered) this.appendLive(entry) + this.notifier.markDirty() + } + + private acceptLive(entry: HistoryEntry): void { + if (this.state === 'loading' || this.stitching) { + this.liveBuffer.push(entry) + return + } + if (this.state !== 'ready') return + const tailSeq = this.tailSeq() + if (tailSeq !== null && entry.event.seq > tailSeq + 1) { + this.liveBuffer.push(entry) + void this.repairGap() + return + } + this.appendLive(entry) + this.notifier.markDirty() + } + + private appendLive(entry: HistoryEntry): void { + const tailSeq = this.tailSeq() + if (tailSeq !== null && entry.event.seq <= tailSeq) return + this.entries = [...this.entries, entry] + } + + private async repairGap(): Promise { + if (this.stitching) return + this.stitching = true + const generation = this.generation + try { + const { result } = await this.api.sessions.history({ + sessionId: this.sessionId, + maxMessages: HISTORY_PAGE_MESSAGES, + }) + if (result.ok && generation === this.generation && this.state === 'ready') { + this.installTail(result.value.events, result.value.hasMore, false) + } + } catch (error) { + console.error('[web-runtime] inspection history gap repair failed:', error) + } finally { + if (generation === this.generation) this.stitching = false + } + } + + private tailSeq(): number | null { + return this.entries.at(-1)?.event.seq ?? null + } + + private buildSnapshot(): SessionHistorySnapshot { + if (this.inspectionCache?.entries !== this.entries) { + const entries = this.entries + this.inspectionCache = { + entries, + value: createHistoryInspection(() => entries), + } + } + return { + state: this.state, + error: this.error, + hasMore: this.hasMore, + inspection: this.inspectionCache.value, + } + } +} diff --git a/packages/client/runtime/src/client/sessions/conversation-context.ts b/packages/client/runtime/src/client/sessions/conversation-context.ts new file mode 100644 index 0000000000..20a8bbcadd --- /dev/null +++ b/packages/client/runtime/src/client/sessions/conversation-context.ts @@ -0,0 +1,23 @@ +import type { ConversationNode } from './conversation.ts' +import type { ConversationPromptSnapshot } from './request-inspection.ts' + +/** Operation that started a new append-only model context. */ +export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' + +/** One immutable model-context generation reconstructed from surface replacements. */ +export interface ConversationContext { + /** Zero-based generation within the session; stable across later appends. */ + id: number + /** Previous generation in this session; absent for the initial context. */ + parentId?: number + /** Why this generation exists; absent for the initial context. */ + origin?: ConversationContextOriginKind + /** Event seq of the replacement that created this generation. */ + originSeq?: number + /** Unix epoch ms of the replacement that created this generation. */ + createdAt?: number + /** Latest request header observed in this generation, inherited until a later header replaces it. */ + prompt?: ConversationPromptSnapshot + /** Final frozen nodes for historical generations, or current folded nodes for the tail. */ + nodes: readonly ConversationNode[] +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index f5f0717236..cd03c92485 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -10,9 +10,26 @@ import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' - export type { TodoItem } +/** Request configuration recorded for one provider call. */ +export interface AssistantRequestConfig { + provider: string + model: string + purpose?: string + thinking?: string + reasoningEffort?: string + temperature?: number + maxTokens?: number + stop?: readonly string[] +} + +/** Stable provider/model identity reported for one completed request. */ +export interface AssistantProvenanceView { + provider: string + model: string +} + /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ export type AssistantBlock = @@ -54,6 +71,16 @@ export interface UserMessageNode { source: unknown } +/** Recorded boundaries used to derive assistant latency and throughput. */ +export interface AssistantTiming { + /** Matching step/start timestamp, or null when it is outside the current event window. */ + stepStartTime: number | null + /** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */ + firstTokenTime: number | null + /** Final assistant/message timestamp. */ + completedTime: number +} + /** A finalized (or interruption-frozen) assistant message. */ export interface AssistantMessageNode { kind: 'assistant' @@ -64,6 +91,10 @@ export interface AssistantMessageNode { step: number blocks: readonly AssistantBlock[] usage?: unknown + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig + /** Timing derived from the recorded step/chunk/message event sequence. */ + timing?: AssistantTiming /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. * Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */ interrupted?: true diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 039c36056b..fc9efb70e4 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -7,7 +7,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // Subpath export (package.json exports "./surface", alias added for this): all value imports // go through it — the package root points at lib/index.js (needs a build) which the vite // browser bundle cannot resolve; surface.ts has no Node dependencies. -import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' +import { + SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, +} from '@deepseek-ai/dsh-session/surface' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, ConversationNode } from './conversation.ts' @@ -33,6 +35,11 @@ function paddingEvent(seq: number): SessionEvent { return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent } +function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { + if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false + return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq +} + /** One event -> UI node (pure function; the six-variant ConversationNode union). */ function materializeNode( event: SessionEvent, @@ -137,7 +144,7 @@ export class FoldAdapter { for (const event of events) this.padded.push(event) this.surface = new SurfaceManager(this.padded) this.nodeCache.clear() - this.degraded = false + this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq)) this.callIdx = new Map() this.resultViews.clear() this.commandIdx = new Map() @@ -160,6 +167,7 @@ export class FoldAdapter { append(event: SessionEvent, view?: ToolEventView): void { this.rev++ this.padded.push(event) + if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true this.indexCall(event, view) this.indexCommand(event) } diff --git a/packages/client/runtime/src/client/sessions/history.ts b/packages/client/runtime/src/client/sessions/history.ts new file mode 100644 index 0000000000..520503a561 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/history.ts @@ -0,0 +1,66 @@ +import type { ToolSchema } from '@deepseek-ai/dsh-llm/types' +import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' +import type { + CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall, +} from './conversation.ts' +import type { ConversationContext } from './conversation-context.ts' +import { projectConversationHistory } from '../session-history/history-fold.ts' +import { inspectRequests, type RequestView } from './request-inspection.ts' + +/** Lazily derived inspection data for one immutable session-history window. */ +export interface SessionHistoryInspection { + eventNodes: readonly ConversationNode[] + contexts: readonly ConversationContext[] + requests: readonly RequestView[] + callSchemas: ReadonlyMap + interruptedNodes: readonly ConversationNode[] + partial: PartialAssistant | null + runningCalls: readonly RunningToolCall[] + codeDispatches: ReadonlyMap +} + +/** + * Create a lazy inspection projection over an immutable history window. + * Conversation consumers retain the cheap wrapper; only Trajectory snapshots + * the entries and replays event order and request lifecycle state. + * @param loadEntries - Lazily snapshots contiguous raw entries in sequence order. + * @returns Lazy, memoized inspection fields for that exact window. + */ +export function createHistoryInspection( + loadEntries: () => readonly HistoryEntry[], +): SessionHistoryInspection { + let entries: readonly HistoryEntry[] | undefined + let conversation: ReturnType | undefined + let requests: ReturnType | undefined + const historyEntries = () => entries ??= loadEntries() + const conversationProjection = () => + conversation ??= projectConversationHistory(historyEntries()) + const requestProjection = () => + requests ??= inspectRequests(historyEntries()) + return { + get eventNodes() { + return conversationProjection().eventNodes + }, + get contexts() { + return conversationProjection().contexts + }, + get interruptedNodes() { + return conversationProjection().interruptedNodes + }, + get partial() { + return conversationProjection().partial + }, + get runningCalls() { + return conversationProjection().runningCalls + }, + get codeDispatches() { + return conversationProjection().codeDispatches + }, + get requests() { + return requestProjection().requests + }, + get callSchemas() { + return requestProjection().callSchemas + }, + } +} diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts new file mode 100644 index 0000000000..e15ad55c8b --- /dev/null +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -0,0 +1,401 @@ +// Request-centric inspection read model. Ordinary generation and compaction +// calls share one chronological projection; presentation-specific grouping +// remains in the trajectory consumer. + +import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types' +import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { + AssistantProvenanceView, AssistantRequestConfig, +} from './conversation.ts' + +export type { + AssistantProvenanceView, AssistantRequestConfig, +} from './conversation.ts' + +/** Complete model-visible request header in force for an ordinary generation. */ +export interface ConversationPromptSnapshot { + /** Provider/model and sampling configuration from the effective request header. */ + config: AssistantRequestConfig + /** Rendered system prompt text; empty when the request had no system prompt. */ + system: string + /** Complete tool catalog sent with the request, including tools that were never called. */ + tools: readonly ToolSchema[] +} + +/** System/tool change introduced while preparing one ordinary request. */ +export interface RequestPromptChange { + /** Sequence of the request/header event that introduced this state. */ + seq: number + /** Unix epoch ms from the request/header event. */ + time: number + /** How the model-visible prompt differs from the previous recorded state. */ + kind: 'initial' | 'system' | 'tools' | 'system-and-tools' + /** State immediately before this change; absent for the initial header. */ + previous?: ConversationPromptSnapshot +} + +/** One provider request reconstructed from durable request lifecycle events. */ +export interface RequestView { + /** Request category; compaction is a purpose, not a separate projection. */ + purpose: 'assistant' | 'compaction' + /** Sequence that opened the operation represented by this request. */ + startSeq: number + turn: number + /** Agent-loop step, or zero for a direct compaction request. */ + step: number + startedAt: number + completedAt: number | null + status: 'running' | 'complete' | 'error' + error?: string + /** Effective ordinary request input, inherited until a later header changes it. */ + prompt?: ConversationPromptSnapshot + /** Prompt change logged while preparing this request. */ + promptChange?: RequestPromptChange + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig + usage?: unknown + /** Assistant message or compaction summary sequence produced by this request. */ + resultSeq?: number + /** Compaction replacement message sequence, when one was committed. */ + replacementSeq?: number + /** Safe compaction summary projection. */ + summary?: readonly ContentBlock[] + /** Complete compaction provider output before the safe projection. */ + rawOutput?: readonly ContentBlock[] + /** Retry ordinal scheduled after a failed ordinary request. */ + retry?: number + maxRetries?: number + retryDelayMs?: number +} + +/** Immutable request-centric projection derived from one history window. */ +export interface RequestInspectionSnapshot { + requests: readonly RequestView[] + callSchemas: ReadonlyMap +} + +/** + * Derive the request-centric read model from one immutable history window. + * Compaction participates as a request purpose rather than a parallel + * top-level collection. + * @param entries - Contiguous raw session history. + * @returns Requests and call-time schemas derived from that history. + */ +export function inspectRequests( + entries: readonly HistoryEntry[], +): RequestInspectionSnapshot { + const events = entries.map(entry => entry.event) + return { + requests: deriveRequests(events), + callSchemas: deriveCallSchemas(events), + } +} + +interface RetryEvent { + type: 'llm/retry' + seq: number + time: number + data: { + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: { message: string } + } +} + +interface CompactionStartEvent { + type: 'compact/start' + seq: number + time: number + data: { turn: number } +} + +interface CompactionSummaryEvent { + type: 'compact/summary' + seq: number + time: number + data: { + summary: readonly ContentBlock[] + rawOutput?: readonly ContentBlock[] + provider: string + model: string + maxTokens?: number + usage?: unknown + } +} + +interface CompactionEndEvent { + type: 'compact/end' + seq: number + time: number + data: { turn: number; error?: string } +} + +function requestKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage { + const previous = current as TokenUsage | undefined + return { + inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens, + outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens, + ...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined + ? {} + : { + cacheReadTokens: + (previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0), + }), + ...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined + ? {} + : { + cacheWriteTokens: + (previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0), + }), + ...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined + ? {} + : { + reasoningTokens: + (previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0), + }), + } +} + +function deriveCallSchemas( + events: readonly SessionEvent[], +): ReadonlyMap { + let active = new Map() + const calls = new Map() + const capture = (callId: string, name: string): void => { + if (calls.has(callId)) return + const schema = active.get(name) + if (schema !== undefined) calls.set(callId, schema) + } + for (const event of events) { + if (event.type === 'request/header') { + const tools: unknown = event.data.header.tools + active = new Map( + Array.isArray(tools) + ? (tools as ToolSchema[]).map(schema => [schema.name, schema]) + : [], + ) + continue + } + if (event.type === 'tool/call') { + capture(String(event.data.callId), event.data.name) + continue + } + const type = event.type as string + if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') { + const data = event.data as unknown as { subCallId: string; name: string } + capture(data.subCallId, data.name) + } + } + return calls +} + +function promptChange( + previous: ConversationPromptSnapshot | undefined, + prompt: ConversationPromptSnapshot, + event: SessionEvent<'request/header'>, +): RequestPromptChange | undefined { + const systemChanged = previous !== undefined && previous.system !== prompt.system + const toolsChanged = previous !== undefined + && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) + if (previous !== undefined && !systemChanged && !toolsChanged) return + return { + seq: event.seq, + time: event.time, + kind: previous === undefined + ? 'initial' + : systemChanged && toolsChanged + ? 'system-and-tools' + : systemChanged + ? 'system' + : 'tools', + ...(previous === undefined ? {} : { previous }), + } +} + +/** Project ordinary and compaction provider calls into one chronological request stream. */ +function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] { + const requests: RequestView[] = [] + const ordinaryByStep = new Map() + let activeStep: string | undefined + let activePrompt: ConversationPromptSnapshot | undefined + let activeCompaction: number | undefined + + const update = (index: number | undefined, change: Partial): void => { + if (index === undefined) return + const request = requests[index] + if (request !== undefined) requests[index] = { ...request, ...change } + } + + for (const sourceEvent of events) { + if (sourceEvent.type === 'step/start') { + const { turn, step } = sourceEvent.data + const key = requestKey(turn, step) + ordinaryByStep.set(key, requests.length) + requests.push({ + purpose: 'assistant', + startSeq: sourceEvent.seq, + turn, + step, + startedAt: sourceEvent.time, + completedAt: null, + status: 'running', + ...(activePrompt === undefined + ? {} + : { prompt: activePrompt, requestConfig: activePrompt.config }), + }) + activeStep = key + continue + } + if (sourceEvent.type === 'request/header') { + const tools: unknown = sourceEvent.data.header.tools + const prompt: ConversationPromptSnapshot = { + config: sourceEvent.data.header.config, + system: sourceEvent.data.header.system ?? '', + tools: Array.isArray(tools) ? tools as ToolSchema[] : [], + } + const change = promptChange(activePrompt, prompt, sourceEvent) + activePrompt = prompt + update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), { + prompt, + requestConfig: prompt.config, + ...(change === undefined ? {} : { promptChange: change }), + }) + continue + } + if ( + sourceEvent.type === 'assistant/chunk' + && sourceEvent.data.chunk.type === 'usage' + ) { + const index = ordinaryByStep.get( + requestKey(sourceEvent.data.turn, sourceEvent.data.step), + ) + const request = index === undefined ? undefined : requests[index] + update(index, { + usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage), + }) + continue + } + if (sourceEvent.type === 'assistant/message') { + const index = ordinaryByStep.get( + requestKey(sourceEvent.data.turn, sourceEvent.data.step), + ) + const request = index === undefined ? undefined : requests[index] + update(index, { + completedAt: sourceEvent.time, + status: 'complete', + resultSeq: sourceEvent.seq, + provenance: { + provider: sourceEvent.data.message.source.provider, + model: sourceEvent.data.message.source.model, + }, + ...(request?.usage !== undefined || sourceEvent.data.usage === undefined + ? {} + : { usage: sourceEvent.data.usage }), + }) + continue + } + if (sourceEvent.type === 'step/end') { + const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step) + const index = ordinaryByStep.get(key) + const request = index === undefined ? undefined : requests[index] + if (request?.status === 'running') { + update(index, { + completedAt: sourceEvent.time, + status: 'error', + }) + } + if (activeStep === key) activeStep = undefined + continue + } + if ((sourceEvent.type as string) === 'llm/retry') { + const event = sourceEvent as unknown as RetryEvent + update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), { + status: 'error', + error: event.data.failure.message, + retry: event.data.retry, + maxRetries: event.data.maxRetries, + retryDelayMs: event.data.delayMs, + }) + continue + } + if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') { + const reason = sourceEvent.data.reason + update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), { + status: 'error', + error: 'failure' in reason ? reason.failure.message : reason.message, + }) + continue + } + + const type = sourceEvent.type as string + if (type === 'compact/start') { + const event = sourceEvent as unknown as CompactionStartEvent + activeCompaction = requests.length + requests.push({ + purpose: 'compaction', + startSeq: event.seq, + turn: event.data.turn, + step: 0, + startedAt: event.time, + completedAt: null, + status: 'running', + }) + continue + } + if (type === 'compact/summary' && activeCompaction !== undefined) { + const event = sourceEvent as unknown as CompactionSummaryEvent + update(activeCompaction, { + resultSeq: event.seq, + summary: event.data.summary, + ...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }), + provenance: { + provider: event.data.provider, + model: event.data.model, + }, + requestConfig: { + provider: event.data.provider, + model: event.data.model, + purpose: 'compaction', + ...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }), + }, + ...(event.data.usage === undefined ? {} : { usage: event.data.usage }), + }) + continue + } + if ( + sourceEvent.type === 'user/message' + && activeCompaction !== undefined + && isCompactionSource(sourceEvent.data.source) + ) { + update(activeCompaction, { replacementSeq: sourceEvent.seq }) + continue + } + if (type !== 'compact/end' || activeCompaction === undefined) continue + const event = sourceEvent as unknown as CompactionEndEvent + update(activeCompaction, { + completedAt: event.time, + status: event.data.error === undefined ? 'complete' : 'error', + ...(event.data.error === undefined ? {} : { error: event.data.error }), + }) + activeCompaction = undefined + } + + return requests.sort((left, right) => left.startSeq - right.startSeq) +} + +function isCompactionSource(source: unknown): boolean { + return typeof source === 'object' + && source !== null + && 'kind' in source + && source.kind === 'plugin' + && 'plugin' in source + && source.plugin === 'compact' +} diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index b40bdb4111..0544d84e7d 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -8,6 +8,7 @@ import { createUserMessage, CallId, createMessage, createToolResultMessage } fro import { describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts' +import { projectConversationHistory } from '../src/client/session-history/history-fold.ts' import { ev, plainTurn } from './event-script.ts' const at = (seq: number, e: Record): SessionEvent => @@ -27,6 +28,7 @@ describe('FoldAdapter', () => { const adapter = new FoldAdapter() adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) const first = adapter.nodes() + expect(adapter.nodes()).toBe(first) adapter.append(ev.user(6, '追加')) const second = adapter.nodes() expect(second.nodes).toHaveLength(3) @@ -35,6 +37,52 @@ describe('FoldAdapter', () => { expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call }) + it('projects frozen surface generations without widening the core live surface', () => { + const events = [ + ev.user(0, 'a'), + ev.user(1, 'b'), + at(2, { + type: 'assistant/message', + surfaceOp: { op: 'replace', start: 0, end: 0 }, + sourceEventSeqs: [0], + data: { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + }), + }, + }), + at(3, { + type: 'assistant/message', + surfaceOp: { op: 'replace', start: 2, end: 1 }, + sourceEventSeqs: [2, 1], + data: { + turn: 1, + step: 2, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'summary 2' }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + }), + }, + }), + ] + + expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({ + id: context.id, + parentId: context.parentId, + originSeq: context.originSeq, + nodes: context.nodes.map(node => node.seq), + }))).toEqual([ + { id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] }, + { id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] }, + { id: 2, parentId: 1, originSeq: 3, nodes: [3] }, + ]) + }) + it('materializes all six node variants with field mapping', () => { const adapter = new FoldAdapter() const events = [ @@ -114,6 +162,68 @@ describe('FoldAdapter', () => { } }) + it('silently degrades when a replacement needs an earlier history page', () => { + const adapter = new FoldAdapter() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + adapter.reset([ + at(10, { + type: 'assistant/message', + surfaceOp: { op: 'replace', start: 1, end: 3 }, + sourceEventSeqs: [1, 3], + data: { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'partial summary' }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + }), + }, + }), + ev.user(11, 'newer message'), + ], 10) + + expect(adapter.nodes()).toMatchObject({ + degraded: true, + nodes: [{ seq: 10 }, { seq: 11 }], + }) + expect(errorSpy).not.toHaveBeenCalled() + } finally { + errorSpy.mockRestore() + } + }) + + it('silently degrades when a live replacement needs an earlier history page', () => { + const adapter = new FoldAdapter() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + adapter.reset([ev.user(10, 'window head')], 10) + adapter.append(at(11, { + type: 'assistant/message', + surfaceOp: { op: 'replace', start: 1, end: 1 }, + sourceEventSeqs: [1], + data: { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'live summary' }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + }), + }, + })) + + expect(adapter.nodes()).toMatchObject({ + degraded: true, + nodes: [{ seq: 10 }, { seq: 11 }], + }) + expect(errorSpy).not.toHaveBeenCalled() + } finally { + errorSpy.mockRestore() + } + }) + it('materializes a tool-result error field when present', () => { const adapter = new FoldAdapter() adapter.reset([ @@ -130,6 +240,44 @@ describe('FoldAdapter', () => { expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } }) }) + it('projects assistant timing and the active request header from history', () => { + const projection = projectConversationHistory([ + ev.stepStart(0, 1, 2), + at(1, { type: 'request/header', data: { + reason: 'initial', + header: { + config: { provider: 'fake', model: 'first' }, + tools: [], + }, + } }), + ev.chunkStart(2, 1, 2), + ev.chunkText(3, 1, 'token', 2), + ev.assistant(4, 1, 'done', 2), + ev.stepStart(5, 2, 1), + ev.chunkText(6, 2, 'next', 1), + ev.assistant(7, 2, 'next done', 1), + ].map(event => ({ event }))) + + expect(projection.eventNodes[0]).toMatchObject({ + kind: 'assistant', + timing: { + stepStartTime: 1_700_000_000_000, + firstTokenTime: 1_700_000_000_003, + completedTime: 1_700_000_000_004, + }, + requestConfig: { provider: 'fake', model: 'first' }, + }) + + expect(projection.eventNodes.at(-1)).toMatchObject({ + timing: { + stepStartTime: 1_700_000_000_005, + firstTokenTime: 1_700_000_000_006, + completedTime: 1_700_000_000_007, + }, + requestConfig: { provider: 'fake', model: 'first' }, + }) + }) + it('exposes the in-window call index for runningCalls material', () => { const adapter = new FoldAdapter() adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0) diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts new file mode 100644 index 0000000000..a76036c076 --- /dev/null +++ b/packages/client/runtime/tests/request-inspection.spec.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { inspectRequests } from '../src/client/sessions/request-inspection.ts' + +const at = (seq: number, type: string, data: unknown): SessionEvent => + ({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent + +const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] => + events.map(event => ({ event })) + +describe('inspectRequests', () => { + it('projects ordinary and compaction calls into one chronological request stream', () => { + const events = [ + at(0, 'step/start', { turn: 1, step: 1 }), + at(1, 'request/header', { + reason: 'initial', + header: { + config: { provider: 'fake', model: 'model' }, + system: 'system', + tools: [{ + name: 'read', + description: 'Read a file.', + parameters: { type: 'object' }, + }], + }, + }), + at(2, 'tool/call', { + turn: 1, + step: 1, + callId: 'call-1', + name: 'read', + arguments: '{}', + }), + at(3, 'assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'done' }], + source: { provider: 'fake', model: 'model' }, + }), + usage: { inputTokens: 5, outputTokens: 2 }, + }), + at(4, 'step/end', { turn: 1, step: 1 }), + at(5, 'compact/start', { turn: 1 }), + at(6, 'compact/summary', { + summary: [{ type: 'text', text: 'summary' }], + rawOutput: [ + { type: 'reasoning', text: 'thought' }, + { type: 'text', text: 'summary' }, + ], + provider: 'fake', + model: 'compact-model', + usage: { inputTokens: 8, outputTokens: 3 }, + }), + at(7, 'user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: { kind: 'plugin', plugin: 'compact' }, + })), + at(8, 'compact/end', { turn: 1 }), + ] + const snapshot = inspectRequests(entriesOf(events)) + expect(snapshot.requests).toMatchObject([ + { + purpose: 'assistant', + startSeq: 0, + resultSeq: 3, + status: 'complete', + prompt: { + config: { provider: 'fake', model: 'model' }, + system: 'system', + }, + promptChange: { seq: 1, kind: 'initial' }, + }, + { + purpose: 'compaction', + startSeq: 5, + resultSeq: 6, + replacementSeq: 7, + status: 'complete', + summary: [{ type: 'text', text: 'summary' }], + }, + ]) + expect(snapshot.callSchemas.get('call-1')?.name).toBe('read') + }) + + it('captures schemas for nested tool dispatches from the active request header', () => { + const snapshot = inspectRequests(entriesOf([ + at(0, 'request/header', { + reason: 'initial', + header: { + config: { provider: 'fake', model: 'model' }, + tools: [{ + name: 'read', + description: 'Read a file.', + parameters: { type: 'object' }, + }], + }, + }), + at(1, 'tool/code-dispatch-start', { + parentCallId: 'parent', + subCallId: 'nested', + name: 'read', + arguments: {}, + }), + ])) + + expect(snapshot.callSchemas.get('nested')?.name).toBe('read') + }) + + it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => { + const chunkUsage = { inputTokens: 21, outputTokens: 3 } + const retryUsage = { + inputTokens: 5, + outputTokens: 2, + cacheReadTokens: 8, + reasoningTokens: 1, + } + const snapshot = inspectRequests(entriesOf([ + at(0, 'step/start', { turn: 1, step: 1 }), + at(1, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: chunkUsage }, + }), + at(2, 'llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 100, + failure: { message: 'rate limited' }, + }), + at(3, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: retryUsage }, + }), + at(4, 'assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'recovered' }], + source: { provider: 'fake', model: 'model' }, + }), + usage: { inputTokens: 1, outputTokens: 1 }, + }), + ])) + + expect(snapshot.requests[0]).toMatchObject({ + status: 'complete', + usage: { + inputTokens: 26, + outputTokens: 5, + cacheReadTokens: 8, + reasoningTokens: 1, + }, + }) + }) + + it('treats a scrubbed durable-fixture tool catalog as unavailable', () => { + const snapshot = inspectRequests(entriesOf([ + at(0, 'step/start', { turn: 1, step: 1 }), + at(1, 'request/header', { + reason: 'initial', + header: { + config: { provider: 'fake', model: 'model' }, + tools: '{{tools}}', + }, + }), + at(2, 'tool/call', { + turn: 1, + step: 1, + callId: 'call-1', + name: 'read', + arguments: '{}', + }), + ])) + + expect(snapshot.callSchemas).toEqual(new Map()) + expect(snapshot.requests[0]?.prompt?.tools).toEqual([]) + }) +}) diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts new file mode 100644 index 0000000000..1375338e90 --- /dev/null +++ b/packages/client/runtime/tests/session-history-source.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import { SessionHistorySource } from '../src/client/session-history/source.ts' +import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { entries, ev, plainTurn } from './event-script.ts' + +const SID = 'history-s1' as SessionId + +function histResponse(events: SessionEvent[], hasMore = false) { + return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) +} + +describe('SessionHistorySource', () => { + it('loads every older page without changing a Chat session', async () => { + const pages = [ + plainTurn(0, 0, '最早问', '最早答'), + plainTurn(6, 1, '中间问', '中间答'), + plainTurn(12, 2, '最新问', '最新答'), + ] + const api = new FakeApiClient() + api.onHistory = (payload) => { + if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true) + if (payload.beforeSeq === 12) return histResponse(pages[1]!, true) + return histResponse(pages[0]!, false) + } + const source = new SessionHistorySource(SID, api) + + await source.loadAll() + + expect(api.callsOf('session.history')).toHaveLength(3) + expect(source.getSnapshot().hasMore).toBe(false) + expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) + .toEqual([1, 3, 7, 9, 13, 15]) + }) + + it('pins a lazy inspection to the entries in its source snapshot', async () => { + const api = new FakeApiClient() + api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) + const source = new SessionHistorySource(SID, api) + await source.loadAll() + const before = source.getSnapshot() + + source.handleMuxFrame({ + type: 'session/event', + sessionId: SID, + event: ev.user(6, 'later'), + }) + + expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3]) + expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) + .toEqual([1, 3, 6]) + }) + + it('stops loading when an older page fails to advance', async () => { + const api = new FakeApiClient() + api.onHistory = payload => payload.beforeSeq === undefined + ? histResponse(plainTurn(6, 1, '新问', '新答'), true) + : Promise.resolve(err({ + code: 'internal', + message: 'page unavailable', + details: {}, + })) + const source = new SessionHistorySource(SID, api) + + await source.loadAll() + + expect(api.callsOf('session.history')).toHaveLength(2) + expect(source.getSnapshot().hasMore).toBe(true) + }) + + it('observes consumer cancellation between older pages', async () => { + const middle = deferred>>() + const olderStarted = deferred() + const api = new FakeApiClient() + api.onHistory = (payload) => { + if (payload.beforeSeq === undefined) { + return histResponse(plainTurn(12, 2, '最新问', '最新答'), true) + } + olderStarted.resolve(undefined) + return middle.promise + } + const source = new SessionHistorySource(SID, api) + const controller = new AbortController() + const complete = source.loadAll(controller.signal) + await olderStarted.promise + controller.abort() + middle.resolve(ok({ + events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[], + hasMore: true, + })) + + await complete + + expect(api.callsOf('session.history')).toHaveLength(2) + expect(source.getSnapshot().hasMore).toBe(true) + }) +}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index c1c6b572f7..c80f046ceb 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -727,6 +727,7 @@ describe('resync', () => { expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9]) }) + }) describe('run_code sub-dispatch indexing', () => { @@ -840,20 +841,23 @@ describe('reference stability (the memo contract)', () => { await session.open() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'c1', 'echo', '{}')) + feed(ev.stepStart(7, 1)) + feed(ev.toolCall(8, 1, 'c1', 'echo', '{}')) session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) const before = session.getSnapshot() - // A chunk storm touches partial/nodes only: runningCalls and pending must keep identity. - feed(ev.chunkStart(8, 1)) - feed(ev.chunkText(9, 1, '与工具无关的流式')) + // A chunk storm touches partial/nodes only: unrelated projections keep identity. + feed(ev.chunkStart(9, 1)) + feed(ev.chunkText(10, 1, '与工具无关的流式')) const after = session.getSnapshot() expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) // And a mutation on the tracked domain swaps that array. - feed(ev.toolResult(10, 1, 'c1', 'ECHO')) + feed(ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() expect(resolved.runningCalls).not.toBe(after.runningCalls) expect(resolved.pending).toBe(after.pending) + feed(ev.assistant(12, 1, '完成')) + expect(session.getSnapshot()).not.toBe(resolved) }) }) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..bf560d4d53 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -6,6 +6,7 @@ * lightningcss inside the bundle: importing `x.module.css` yields the * hashed class map, and the css text auto-injects a