diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 82047e9d87..5e423f9383 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -1,6 +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 -2026-07-26-subprocess-consumer-migration.md: b31f69f1251a7219e168ed7800ba16ff7d6b328c -2026-07-26-subprocess-consumer-migration.zh.md: 47e7519a72f5482f255d15d87b483e9295f6cd2c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +2026-07-26-subprocess-consumer-migration.md: 477dffc2271db08b8986b34645067b247ad7ace7 +2026-07-26-subprocess-consumer-migration.zh.md: 5e1035872c6e2101e41d2801cccdfb5ef2c088fc diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md index b31f69f125..477dffc227 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -6,7 +6,7 @@ English | [中文](2026-07-26-subprocess-consumer-migration.zh.md) ## Problem -The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, mcp-client and pty-local and the SDK helper each had a third/fourth/fifth copy of the credential scrub — and none of it was swappable or centrally testable. +The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, and mcp-client, pty-local, the SDK helper, and the TUI Git probe each carried another credential scrub — and none of it was swappable or centrally testable. ## Decision @@ -15,7 +15,7 @@ The seam's vocabulary is now Node-shaped, and every spawner that can ride the se - **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch). - **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome. - **Tree-scoped termination behind one verb**: `terminate()` owns the SIGTERM→grace→SIGKILL escalation (serves the spec's abort signal too, and is a no-op once the tree is gone) — the handle exposes no single-signal `kill(signal?)`, so a consumer cannot skip the grace window; `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. (The stdin-EOF-first dispose ladder initially absorbed from `subagent-subprocess` later moved back out to its one consumer — see the [ladder-ownership Agent Note](2026-07-27-dispose-ladder-to-consumer.md).) -- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well. +- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam, and credential-shaped names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, case-insensitively. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork), mcp-client (the MCP SDK owns the transport spawn), and the TUI's synchronous Git branch probe — import the function, so environment policy is single-sourced even where process ownership is not. The SDK helper's `scrubEnvironment()` defaults through the function and applies the exported pattern when its caller supplies an explicit environment; the pattern remains public for this production consumer. Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). @@ -23,16 +23,16 @@ Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` ## Alternatives considered -**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and five of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams. +**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and six of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams. **A single `stdio: 'pipe' | 'inherit' | 'collect'` mode for all three streams at once.** Rejected: real consumers mix modes per stream (lsp: pipe/pipe/collect; acp: pipe/pipe/inherit; bash: data/collect/collect). Per-stream dispositions are exactly Node's shape and avoid a second spawn call for the mixed cases. **Migrate pty-local and mcp-client spawns too.** Rejected on ownership grounds, not scope: node-pty's `fork()` allocates the terminal itself, and the MCP SDK's `StdioClientTransport` spawns internally — neither call site is ours to route. They adopt the shared scrub (the part that is policy), and their READMEs say why the spawn stays put. -**Migrate the test-support launchers (acp-snapshot, loader-smoke) and the SDK package-manager runner.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams, and the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; it shares the scrub instead. +**Migrate the test-support launchers (acp-snapshot, loader-smoke), the SDK package-manager runner, and the TUI Git probe.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams; the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; and the TUI probe is synchronous. The production callers share the scrub instead. ## Consequences Bought: one implementation of tree signalling, escalation, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. -Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. +Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/TUI/test-support spawns remain outside the service by ownership or execution shape, with the scrub as the shared floor and its pattern intentionally exported for explicit-environment consumers. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 47e7519a72..5e1035872c 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local 与 SDK helper 则各自持有凭据清除的第三、第四、第五份副本——而这一切既不可替换,也无法集中测试。 +[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local、SDK helper 与 TUI Git 探测则各自持有另一份凭据清除——而这一切既不可替换,也无法集中测试。 ## 决策 @@ -15,7 +15,7 @@ Status: implemented - **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 - **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界)。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。(最初从 `subagent-subprocess` 吸收的以 stdin EOF 打头的 dispose 阶梯,后来又移回其唯一消费方——见[阶梯归属 Agent Note](2026-07-27-dispose-ladder-to-consumer.md)。) -- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 +- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上,且凭据形状的名称不区分大小写地包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN`。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)、mcp-client(MCP SDK 拥有传输层的 spawn)与 TUI 的同步 Git 分支探测——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源。SDK helper 的 `scrubEnvironment()` 默认委托给该函数,并在调用方显式传入环境时应用导出的正则;该正则因此生产消费方而保持公开。 各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 @@ -23,16 +23,16 @@ Status: implemented ## 曾考虑的替代方案 -**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。 +**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和六份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。 **用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式一次性统辖全部三条流。**否决:真实消费方按流混用模式(lsp:pipe/pipe/collect;acp:pipe/pipe/inherit;bash:data/collect/collect)。按流划分的处置方式恰好就是 Node 的形状,也免去了混用场景的第二个 spawn 调用。 **把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。 -**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 +**迁移 test-support 启动器(acp-snapshot、loader-smoke)、SDK package-manager 运行器与 TUI Git 探测。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;TUI 探测则是同步调用。这些生产调用点改为共享凭据清除。 ## 后果 换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 -代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/TUI/test-support 的 spawn 因所有权归属或执行形状留在该服务之外,以凭据清除作为共底线,且为显式环境的生产消费方有意保持该正则导出。 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 64d1f57f51..774b9cc52e 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -1,6 +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 -2026-06-22-acp-subagent-backend.md: a45ce5e34873249969bbe4dabb87a89d10246b3d -2026-06-22-acp-subagent-backend.zh.md: 3b6a11efc1ae0427eb5ce3b30029b77b00d6801a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +2026-06-22-acp-subagent-backend.md: 5f12aa1c08d4f4cfaa35f2f7f4b09ad341c3eae8 +2026-06-22-acp-subagent-backend.zh.md: 61359246b0c552f6126cd82ae843d489de809a38 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index a45ce5e348..5f12aa1c08 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -34,7 +34,7 @@ ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `ma ### Security: scrubbed child environment -The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error. +The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|PASSWORD|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error. ## Testing diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 3b6a11efc1..61359246b0 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -34,7 +34,7 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ### 安全:清洗子进程环境 -子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。 +子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|PASSWORD|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 940908457f..7e9f4d52e6 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -1,6 +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 -2026-07-07-mcp-client-plugin.md: 2a8ad62e3bad1f2ae47100294f5dba25ceb47d12 -2026-07-07-mcp-client-plugin.zh.md: 9860d5ec805b394eb0c5f59f54349264593a5599 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +2026-07-07-mcp-client-plugin.md: 756a5c4dc9f1152ecb1955d93b8dc47fcf07c661 +2026-07-07-mcp-client-plugin.zh.md: d2ff04d68033402fbb2718f8624e44d35d860db3 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index 2a8ad62e3b..756a5c4dc9 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -149,7 +149,7 @@ A unified `execute` handler for all tools from one MCP server: ### Subprocess environment (stdio transport) -Replicate the `buildChildEnv` + `SENSITIVE_ENV_PATTERN` scrub from `dsh-subagent-acp`: filter ambient env (strip credential-shaped vars matching `/KEY|SECRET|TOKEN/i`), then merge `config.env` on top. Explicit env overrides survive the scrub. +Build the child environment from the subprocess seam's shared `scrubbedParentEnv()` base, which removes ambient names matching `/KEY|PASSWORD|SECRET|TOKEN/i` and ambient `DSH_*` names, then merge `config.env` on top. Explicit env overrides survive the scrub. ### Disconnection / crash diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 9860d5ec80..d2ff04d680 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -149,7 +149,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp ### 子进程环境(stdio 传输) -复用 `dsh-subagent-acp` 的 `buildChildEnv` + `SENSITIVE_ENV_PATTERN` 清洗逻辑:过滤环境变量(剥离匹配 `/KEY|SECRET|TOKEN/i` 的凭证形变量),然后将 `config.env` 覆盖合并到顶层。显式配置的 env 不受清洗影响。 +以子进程服务边界共享的 `scrubbedParentEnv()` 为基础构建子进程环境;该基础环境会移除环境中匹配 `/KEY|PASSWORD|SECRET|TOKEN/i` 的名称以及 `DSH_*` 名称,然后在其上合并 `config.env`。显式配置的 env 覆盖在清洗后仍会保留。 ### 断连 / 崩溃 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index be4fc8f19f..2de2288506 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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 .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 691ccd4341837d63bb48d27c2a3fb007657fe7ba -2026-07-16-persistent-pty-sessions.zh.md: 86d57f79ee74429542730ebcad60bc9f3c9f15ca +2026-07-16-persistent-pty-sessions.md: d7d06dc8517780a37889e4f94dd0b99475dc5432 +2026-07-16-persistent-pty-sessions.zh.md: 7783fa4b4f3056ab3b3088a4e9618f45d030ccbf diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 691ccd4341..d7d06dc851 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -40,7 +40,7 @@ Agent-scope disposal closes registrations first, then awaits quiescent teardown A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning: -- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them. +- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*PASSWORD*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them. - It requires `ctx.sandbox` and the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default and wraps the shell argv once; that mode and workspace root remain the process boundary for the PTY lifetime. A write that would change the effective `sandbox/mode` is rejected before commit while the owner has any open PTY or unpublished spawn, with an instruction to wait for creation to settle and close those sessions first; same-effective-mode writes remain valid. The pending reservation spans backend setup through publication, so there is no race in which a wider terminal appears after a downgrade. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass. Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 86d57f79ee..7783fa4b4f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -40,7 +40,7 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出 注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护: -- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。 +- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*PASSWORD*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。 - 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。只要 owner 有任何已打开的 PTY 或尚未发布的 spawn,任何会改变生效 `sandbox/mode` 的写入都会在提交前被拒绝,并提示先等待创建操作结算,再关闭这些会话;不会改变生效模式的写入仍然有效。这项进行中的预留从后端 setup 持续到发布完成,因此不存在降级后又出现权限更宽的终端这一竞态。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。 沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。 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/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index b14e969a84..62fc537c2f 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.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 .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md -2026-07-04-prune-dead-core-spine-surface.md: a6c608617415f3af07de5c95fd20b0bde40bdef3 -2026-07-04-prune-dead-core-spine-surface.zh.md: 83603d8a8432b99d8f42222442b38005e196ac4e +2026-07-04-prune-dead-core-spine-surface.md: 473f655ab0944b43f9b1193413801eb7a80286d8 +2026-07-04-prune-dead-core-spine-surface.zh.md: 00c6f7acca403c937ff765b6d73d3d0fa0c3153a diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index a6c6086174..473f655ab0 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -19,7 +19,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime | `code-runtime-worker` protocol/bootstrap re-exports | Outside-package production/e2e consumers use `WorkerCodeRuntime` and config, not `BootstrapPort`, `PatchableStream`, or worker message/boot types. | Keep the runtime class/config contract and make its wire/bootstrap vocabulary source-private. | | ACP `agentOptions` root export | The helper has only same-file and ACP-test consumers; the sole outside-package production consumer mounts the plugin namespace. | Keep `name`, `inject`, `Config`, `AcpConfig`, and `apply`; make `agentOptions` source-private and test it through bridge behavior. | | `providerWording` and `completedTurnPrefix` root exports | Each has one same-package production caller; only the balanced-prefix helper has a same-package white-box test. | Make them source-private and test provider behavior. | -| `depthOf`, `SubagentDepthError`, `SENSITIVE_ENV_PATTERN`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. | Keep depth/environment/exit behavior but make the helpers and error/regex source-private; test through spawn and disposal. | +| `depthOf`, `SubagentDepthError`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. `SENSITIVE_ENV_PATTERN` is excluded because the SDK helper applies it to caller-supplied environments. | Keep depth and exit behavior but make the remaining helpers and error source-private; test through spawn and disposal. Keep the shared credential pattern public. | | `PersistenceCoordinator.inits`, backend `inits` accessors, `seedCoversPrefix`, and `assertSerializable` | The accessors exist for white-box tests; `seedCoversPrefix` has no outside production importer; `assertSerializable` has no production caller and duplicates the coordinator append boundary's lossless snapshot. | Observe initialization through `session/flush`, make `seedCoversPrefix` source-private, and delete `assertSerializable`. Keep both backends, `SessionHeader`, and SQLite's version contract. | | `LlmError.status` and replay status | Adapters/replay populate it, but production branches on stable error code/message and never reads raw status. | Remove the unread field and replay plumbing while preserving error classification. | | `BlockAssembler.push()` return value | Both production callers ignore the returned completed block. | Return `void`; keep the deliberately public `blocks()`/`message()` contract. | diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md index 83603d8a84..00c6f7acca 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -19,7 +19,7 @@ Status: proposed | `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇改为源码私有。 | | ACP 的 `agentOptions` 根导出 | 该辅助函数只有同文件和 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 `agentOptions` 改为源码私有,通过桥接层行为测试。 | | `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 | -| `depthOf`、`SubagentDepthError`、`SENSITIVE_ENV_PATTERN`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。 | 保留深度/环境/退出行为,但将辅助函数和 error/regex 改为源码私有;通过 spawn 和 dispose 测试。 | +| `depthOf`、`SubagentDepthError`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。`SENSITIVE_ENV_PATTERN` 不在其中,因为 SDK helper 会将它应用于调用方传入的环境。 | 保留深度与退出行为,但将剩余辅助函数和 error 改为源码私有;通过 spawn 和 dispose 测试。保持共享凭据正则公开。 | | `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 | | `LlmError.status` 与回放 status | 适配器/回放填充它,但生产分支基于稳定的错误码/消息判断,从不读取原始 status。 | 移除未读字段和回放管道,保留错误分类。 | | `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 契约。 | 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/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 96c938a74f..674cd63efe 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -1,6 +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 -defensive-patterns.md: c69094db461048f5dbca5f8bdd1fb5581b08a962 -defensive-patterns.zh.md: eb57f035ad0bd67e62e285d451502d41e4efc2bc +# pnpm run verify-translation-pairing --write docs/defensive-patterns.md +defensive-patterns.md: cc34877fb0d6a2e1740d8fa138f879363c8e69a3 +defensive-patterns.zh.md: 21b0977d8167ffecc21cdfab3c778efceefd8f03 diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index c69094db46..cc34877fb0 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -26,4 +26,4 @@ A user-supplied listener that throws must not reject the promise it runs inside ## Never hand untrusted output the ambient environment or predictable paths -Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. +Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index eb57f035ad..21b0977d81 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -26,4 +26,4 @@ ## 绝不将环境变量或可预测路径暴露给不可信输出 -spawn 的命令应获得一份经过清洗的 env(去除 `*KEY*`/`*SECRET*`/`*TOKEN*`),使 harness 凭证无法泄漏到输出、`env` 或溢出文件中。临时/溢出文件应使用私有(0700)目录、随机文件名和排他的仅所有者可访问打开方式(`'wx'`、`0o600`)——可预测的全局可读路径会招致符号链接竞争和信息泄露。 +spawn 的命令应获得一份经过清洗的 env(去除 `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`),使 harness 凭证无法泄漏到输出、`env` 或溢出文件中。临时/溢出文件应使用私有(0700)目录、随机文件名和排他的仅所有者可访问打开方式(`'wx'`、`0o600`)——可预测的全局可读路径会招致符号链接竞争和信息泄露。 diff --git a/docs/module-graph.md b/docs/module-graph.md index dc31bfc5b1..98902f621f 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 @@ -854,6 +855,7 @@ flowchart TD pkg_tui --> pkg_session_reference pkg_tui --> pkg_session_title pkg_tui --> pkg_skill + pkg_tui --> pkg_subprocess pkg_tui --> pkg_system_prompt pkg_tui --> pkg_token_meter pkg_tui --> pkg_tools @@ -989,7 +991,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 +1009,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) | @@ -1122,7 +1124,7 @@ flowchart TD | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a1535f36dc..0c76a37188 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 2ee50361ff..0d6676ec97 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: 25eb60e2c95059ae918669c9f5169b6b8e9c6816 -README.zh.md: 8ac2ea10884aee48775dc5c545fac414edcddefe +README.md: d283cf19572f4888d17884472ea0d2272109de7f +README.zh.md: b2d479e1ba277738390de2122c295ce44c77b1e0 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 25eb60e2c9..d283cf1957 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 8ac2ea1088..b2d479e1ba 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 c7be330d55..3f6388b9eb 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -701,6 +701,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', () => { @@ -814,20 +815,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/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 5ec4652aef..408badeb42 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -107,6 +107,7 @@ export class FixtureSession implements SessionFace { loadOlder(): never { throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`) } + } /** One live test session: fixture-derived stores plus its minted scope state. */ 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