From d99533177274d3019b3ea14a381a5b84b56b727a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 11:07:25 +0800 Subject: [PATCH 01/37] feat(web): start the turn-level code diff card for #3501 From f937f4e23b1f4ee76e73e8f87f8c48a1ff8898ed Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 16:47:40 +0800 Subject: [PATCH 02/37] feat(web): record turn file changes with git snapshots and render the changed-files card Add the dsh-workspace-changes Host plugin that snapshots the working tree at turn start and end through a private git index, diffs the two trees, folds in file-tool hunks for ignored and out-of-tree files, and appends a log-only workspace/changes event. The Web deliverables plugin renders that event as the changed-files card in place of the mutation-call row, with default-app and common-folder opening through a new authenticated route. --- ...8-present-workspace-source-files.i18n.yaml | 4 +- ...26-09-08-present-workspace-source-files.md | 2 +- ...09-08-present-workspace-source-files.zh.md | 2 +- ...26-09-11-turn-changed-files-card.i18n.yaml | 6 + .../2026-09-11-turn-changed-files-card.md | 45 +++ .../2026-09-11-turn-changed-files-card.zh.md | 45 +++ apps/web/package.json | 9 +- apps/web/tests/changed-files.e2e.ts | 148 ++++++++ ....overlay.yml => changed-files.overlay.yml} | 2 +- apps/web/tests/produced-files.e2e.ts | 216 ----------- apps/web/tests/scaffold.ts | 11 + apps/web/tsconfig.json | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 24 ++ docs/config-catalog.zh.md | 24 ++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 8 +- docs/event-producer-consumer.zh.md | 8 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 7 + docs/module-graph.zh.md | 7 + docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 13 + docs/persistence-catalog.zh.md | 13 + packages/bundle/web-app/cordis.patch.yml | 10 +- packages/bundle/web-app/package.json | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 25 +- packages/client/ui-deliverables/README.zh.md | 25 +- packages/client/ui-deliverables/package.json | 3 +- .../client/ui-deliverables/src/changes.ts | 43 +++ .../src/client/ChangedFiles.module.css | 26 ++ .../src/client/ChangedFiles.tsx | 113 ++++++ .../src/client/Deliverables.module.css | 2 +- .../src/client/Deliverables.tsx | 32 +- .../src/client/ProducedFiles.module.css | 159 --------- .../src/client/ProducedFiles.tsx | 61 ---- .../ui-deliverables/src/client/index.ts | 20 +- .../ui-deliverables/src/client/locales.ts | 34 +- .../src/client/present-open.ts | 22 +- .../src/client/turn-deliverables.ts | 41 ++- .../ui-deliverables/src/present-open.ts | 166 ++++++--- .../tests/changes-open.host.spec.ts | 146 ++++++++ ....spec.tsx => deliverables.client.spec.tsx} | 215 +++++++---- .../tests/present-open.client.spec.ts | 16 + .../tests/turn-tail-spacing.client.spec.ts | 6 +- .../ui-deliverables/tsconfig.client.json | 4 + .../client/ui-deliverables/tsconfig.host.json | 4 + .../core/session/src/known-event-types.ts | 1 + packages/fs/README.i18n.yaml | 4 +- packages/fs/README.md | 1 + packages/fs/README.zh.md | 1 + .../fs/workspace-changes/README.i18n.yaml | 6 + packages/fs/workspace-changes/README.md | 101 ++++++ packages/fs/workspace-changes/README.zh.md | 101 ++++++ packages/fs/workspace-changes/package.json | 58 +++ packages/fs/workspace-changes/src/git.ts | 213 +++++++++++ packages/fs/workspace-changes/src/index.ts | 149 ++++++++ packages/fs/workspace-changes/src/numstat.ts | 81 +++++ packages/fs/workspace-changes/src/paths.ts | 78 ++++ packages/fs/workspace-changes/src/recorder.ts | 188 ++++++++++ packages/fs/workspace-changes/src/types.ts | 38 ++ .../fs/workspace-changes/tests/git.spec.ts | 125 +++++++ .../tests/loader-composition.spec.ts | 82 +++++ .../workspace-changes/tests/numstat.spec.ts | 46 +++ .../fs/workspace-changes/tests/paths.spec.ts | 46 +++ .../fs/workspace-changes/tests/plugin.spec.ts | 334 ++++++++++++++++++ .../fs/workspace-changes/tests/support.ts | 67 ++++ packages/fs/workspace-changes/tsconfig.json | 33 ++ pnpm-lock.yaml | 49 +++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 +- tsconfig.host.json | 3 +- 76 files changed, 2952 insertions(+), 655 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md create mode 100644 .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md create mode 100644 apps/web/tests/changed-files.e2e.ts rename apps/web/tests/{produced-files.overlay.yml => changed-files.overlay.yml} (75%) delete mode 100644 apps/web/tests/produced-files.e2e.ts create mode 100644 packages/client/ui-deliverables/src/changes.ts create mode 100644 packages/client/ui-deliverables/src/client/ChangedFiles.module.css create mode 100644 packages/client/ui-deliverables/src/client/ChangedFiles.tsx delete mode 100644 packages/client/ui-deliverables/src/client/ProducedFiles.module.css delete mode 100644 packages/client/ui-deliverables/src/client/ProducedFiles.tsx create mode 100644 packages/client/ui-deliverables/tests/changes-open.host.spec.ts rename packages/client/ui-deliverables/tests/{produced-files.client.spec.tsx => deliverables.client.spec.tsx} (73%) create mode 100644 packages/fs/workspace-changes/README.i18n.yaml create mode 100644 packages/fs/workspace-changes/README.md create mode 100644 packages/fs/workspace-changes/README.zh.md create mode 100644 packages/fs/workspace-changes/package.json create mode 100644 packages/fs/workspace-changes/src/git.ts create mode 100644 packages/fs/workspace-changes/src/index.ts create mode 100644 packages/fs/workspace-changes/src/numstat.ts create mode 100644 packages/fs/workspace-changes/src/paths.ts create mode 100644 packages/fs/workspace-changes/src/recorder.ts create mode 100644 packages/fs/workspace-changes/src/types.ts create mode 100644 packages/fs/workspace-changes/tests/git.spec.ts create mode 100644 packages/fs/workspace-changes/tests/loader-composition.spec.ts create mode 100644 packages/fs/workspace-changes/tests/numstat.spec.ts create mode 100644 packages/fs/workspace-changes/tests/paths.spec.ts create mode 100644 packages/fs/workspace-changes/tests/plugin.spec.ts create mode 100644 packages/fs/workspace-changes/tests/support.ts create mode 100644 packages/fs/workspace-changes/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 9d3acb0e07..604b890247 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.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-09-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: 650c16fe5a614ce1ccf118744a5f0e8cb5d19f4b -2026-09-08-present-workspace-source-files.zh.md: 61bca82c83ff42600a7017039a6be4c3a4605769 +2026-09-08-present-workspace-source-files.md: 40a5656eee1d90be09c10660706584e6b61848e9 +2026-09-08-present-workspace-source-files.zh.md: 647728ec7befd4d7ecefba29af7d89de2469aac2 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index 650c16fe5a..40a5656eee 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -16,7 +16,7 @@ The tool description requires `present` after writing a file the user asked to r The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. -An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. +An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The [changed-files card](2026-09-11-turn-changed-files-card.md) lists the turn's recorded changes beside the delivery cards; neither surface implies final delivery. File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and verify the Session filesystem and Host path; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index 61bca82c83..647728ec7b 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -16,7 +16,7 @@ Status: implemented 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 -经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 +经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。[改动文件卡片](2026-09-11-turn-changed-files-card.zh.md)在交付卡片旁列出本轮记录的改动;两个表面均不表示最终交付。 文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并验证 Session 文件系统与 Host 路径;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml new file mode 100644 index 0000000000..fbb4412b0d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md +2026-09-11-turn-changed-files-card.md: f04e694853a89be75c642325d636c6a8fd496ddb +2026-09-11-turn-changed-files-card.zh.md: 52474eaa4dc4b312dadb475c590e25146944c922 diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md new file mode 100644 index 0000000000..f04e694853 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -0,0 +1,45 @@ +# Agent Note: Turn changed-files card + +Status: implemented + +English | [中文](2026-09-11-turn-changed-files-card.zh.md) + +## Problem + +After a turn, users want to see which files the model changed and by how much. The Web turn tail listed only the paths of successful `write`, `edit`, and `str_replace_editor` calls, without line counts, and missed every file a shell command changed; the per-call diff cards in the message flow answered the question one call at a time. + +## Decision + +The Host [workspace-changes](../../../../packages/fs/workspace-changes/README.md) plugin records each top-level turn's changed files as a `workspace/changes` Session event, and the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) renders that event as the changed-files card in place of the mutation-call row. The event is log-only and never model-visible. + +The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it. + +Git is the default executable on `PATH`; no environment plugin is consulted. Three tiers cover the workspace: a working directory inside a repository uses that repository; a working directory outside any repository uses a shadow repository under the Harness home, with its git directory there and its work tree pointing at the working directory, so the user's directory gains no `.git`; without git the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. Shadow repositories take their excludes from configuration because no `.gitignore` exists there. + +Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, so no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. + +The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar. The event carries both snapshot tree ids for a later full-file comparison. + +The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered and the card renders from the log alone. The Client keeps the latest event per turn. + +## Alternatives considered + +**Extending the mutation-call row with hunk counts** kept a second, weaker implementation beside the per-call diff cards and still missed shell edits; git sees every write regardless of the tool. + +**Diffing against `HEAD` at turn end** needs one command but attributes the user's uncommitted work to the turn. + +**A git tag or `stash create` per turn** leaves refs in the user's repository or omits untracked files; a tree written through a private index does neither. + +**A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; the card is simply absent until git exists. + +**Recording the pre-edit file content at first touch** would make hunk counts exact and enable full-file diffs for uncovered files, but requires the file tools to hand their pre-read content to the recorder; the tree ids and persisted hunks leave that open. + +**An environment-provider seam for locating git** was raised by the team but not settled; the plugin uses `PATH` and keeps its lookup in one place. + +## Consequences + +Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the repository's object store; unreferenced trees are pruned by the repository's own garbage collection, after which only the recorded counts remain. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. + +The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. + +Focused tests cover repository and shadow tiers with real git, coverage classification, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenarios replay the recorder end to end. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md new file mode 100644 index 0000000000..52474eaa4d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 本轮改动文件卡片 + +Status: implemented + +[English](2026-09-11-turn-changed-files-card.md) | 中文 + +## Problem + +一轮结束后,用户想看到模型改了哪些文件、改了多少。Web 的轮尾只列出成功的 `write`、`edit` 与 `str_replace_editor` 调用的路径,没有行数,也漏掉所有 shell 命令改动的文件;消息流中逐调用的 diff 卡片只能一次回答一个调用。 + +## Decision + +Host 侧的 [workspace-changes](../../../../packages/fs/workspace-changes/README.zh.md) 插件把每个顶层轮次改动的文件记录为 `workspace/changes` Session 事件,[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)把该事件渲染为改动文件卡片,取代修改调用行。该事件仅写日志,模型永远看不到。 + +记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。 + +git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。工作区分三档:位于仓库内的工作目录使用该仓库;不在任何仓库内的工作目录使用 Harness home 下的影子仓库,其 git 目录在那里而工作树指向工作目录,用户目录里不会多出 `.git`;没有 git 时插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。影子仓库的排除规则来自配置,因为那里没有 `.gitignore`。 + +快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,因此不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 + +列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。事件携带两个快照 tree id,供将来做整文件对比。 + +记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖,卡片仅凭日志即可渲染。Client 保留每轮最新的事件。 + +## Alternatives considered + +**给修改调用行补上 hunk 计数**会在逐调用 diff 卡片旁保留第二个更弱的实现,且仍然漏掉 shell 编辑;git 不管工具是什么都能看到每次写入。 + +**轮末直接与 `HEAD` 比较**只需一条命令,但会把用户未提交的工作算到本轮。 + +**每轮打一个 git tag 或 `stash create`**会在用户仓库里留下 ref,或者遗漏未跟踪文件;通过私有 index 写出的树两者都不会发生。 + +**为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片直接不显示。 + +**在首次触碰时记录编辑前的文件内容**能让 hunk 计数精确并为未覆盖文件提供整文件 diff,但要求文件工具把预先读取的内容交给记录器;tree id 与持久化的 hunk 为此留出了空间。 + +**用于定位 git 的环境提供者接缝**由团队提出但尚未定案;插件使用 `PATH` 并把查找收敛在一处。 + +## Consequences + +每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入仓库对象库;无引用的树由仓库自己的垃圾回收清理,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 + +只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景新增该事件与卡片。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 + +聚焦测试用真实 git 覆盖仓库与影子两档、覆盖范围分类、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器。 diff --git a/apps/web/package.json b/apps/web/package.json index cc504af02b..7131333ca3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,23 +38,24 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^", "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-workspace-changes": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@types/ws": "8.18.1", "@vitejs/plugin-react": "^4.0.0", - "http-server": "^14.1.1", "fflate": "^0.8.2", + "http-server": "^14.1.1", "playwright": "^1.49.0", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^6.0.3", "vite": "^6.0.0", "vitest": "^4.1.8", - "ws": "8.21.0", - "@deepseek-ai/dsh-launch-environment": "workspace:^" + "ws": "8.21.0" } } diff --git a/apps/web/tests/changed-files.e2e.ts b/apps/web/tests/changed-files.e2e.ts new file mode 100644 index 0000000000..173bac9d2d --- /dev/null +++ b/apps/web/tests/changed-files.e2e.ts @@ -0,0 +1,148 @@ +// Web e2e scenario: the changed-files card a finished turn ends with. +// Cold-seeds one recorded change summary (zero model calls), then verifies the +// real assembled card folds after three rows, expands to the complete list, +// and offers native file and folder actions when the Host desktop is pinned on. +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import type {} from '@deepseek-ai/dsh-workspace-changes' +import { + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const MODE = webSnapshotMode() +const OVERLAY = fileURLToPath(new URL('./changed-files.overlay.yml', import.meta.url)) +const SEED_ID = 'changed-files-web-e2e' +const DONE = 'CHANGED_FILES_DONE' + +/** Eleven files in display order, as the Host records them. */ +const CHANGED = [ + ['config/design-token', 42, 11], + ['config/feature-flags.json', 143, 32], + ['config/launch-plan.yaml', 654, 9], + ['src/app.ts', 120, 80], + ['src/index.html', 30, 4], + ['src/manifest.yaml', 12, 0], + ['src/notes.txt', 8, 8], + ['src/preview.svg', 0, 0], + ['src/schema.json', 90, 60], + ['src/styles.css', 100, 100], + ['关于我.md', 33, 22], +] as const + +/** Build one settled turn whose recorded summary lists eleven changed files. */ +function changedFixture(): string { + const session = Session.create(SessionId('changed-files-source')) + const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Update the site files.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Changed files fold', messageSeqs: [user.seq], source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + stream: [], + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `Updated the site.\n\n${DONE}` }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('workspace/changes', { + turn: 1, + total: CHANGED.length, + snapshot: { before: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', after: 'c1a2b3d4e5f60718293a4b5c6d7e8f9012345678' }, + files: CHANGED.map(([path, added, deleted]) => ({ + path, display: path, added, deleted, ...(path.endsWith('.svg') ? { binary: true as const } : {}), + })), + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', + createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, + }), + ...session.snapshotEvents().map(event => JSON.stringify({ + ...event, time: eventTimeOrigin + event.seq * 1_000, + })), + '', + ].join('\n') +} + +describe('web e2e: a finished turn ends with the files it changed', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + await seedSession(scaffold, changedFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.setViewportSize({ width: 1400, height: 900 }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('folds an eleven-file summary after three rows and expands it in place', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-changed-files')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + const card = page.locator('[data-changed-files]') + await card.waitFor({ timeout: 15_000 }) + await expect.poll(() => card.getByText('Edited 11 files', { exact: true }).count()).toBe(1) + expect(await card.getByText('+1,232', { exact: true }).count()).toBe(1) + expect(await card.getByText('-326', { exact: true }).count()).toBe(1) + const rows = card.getByRole('listitem') + await expect.poll(() => rows.count()).toBe(3) + expect(await rows.nth(0).innerText()).toContain('config/design-token') + expect(await rows.nth(0).innerText()).toContain('+42') + expect(await rows.nth(2).innerText()).toContain('config/launch-plan.yaml') + // The pinned desktop turns the header into the folder action and rows into default-app opens. + await expect.poll(() => card.getByRole('button', { name: 'Open the folder containing the changed files' }).count()).toBe(1) + expect(await card.getByRole('button', { name: 'Open config/design-token in default app' }).count()).toBe(1) + + const expand = card.getByRole('button', { name: 'Show all 11 changed files' }) + expect(await expand.innerText()).toContain('All 11 files') + await expand.click() + await expect.poll(() => rows.count()).toBe(11) + expect(await rows.nth(7).innerText()).toContain('binary') + expect(await rows.nth(10).innerText()).toContain('关于我.md') + const collapse = card.getByRole('button', { name: 'Collapse changed files' }) + expect(await collapse.getAttribute('aria-expanded')).toBe('true') + // The collapse control is the card's last element after expansion. + expect(await card.evaluate(element => element.lastElementChild?.getAttribute('aria-expanded'))).toBe('true') + await collapse.click() + await expect.poll(() => rows.count()).toBe(3) + expect(await page.getByText('Files changed', { exact: true }).count()).toBe(0) + + const geometry = await card.evaluate(element => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth })) + expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tests/produced-files.overlay.yml b/apps/web/tests/changed-files.overlay.yml similarity index 75% rename from apps/web/tests/produced-files.overlay.yml rename to apps/web/tests/changed-files.overlay.yml index 4267b6d2cf..165251842b 100644 --- a/apps/web/tests/produced-files.overlay.yml +++ b/apps/web/tests/changed-files.overlay.yml @@ -1,4 +1,4 @@ -# The summary test asserts the native-folder action without launching it. Pin +# The card test asserts the native folder and file actions without launching them. Pin # the capability so headless Linux CI and desktop developer hosts expose the # same UI branch; platform opener behavior belongs to the Host unit tests. - id: session-controller diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts deleted file mode 100644 index 8ee9644030..0000000000 --- a/apps/web/tests/produced-files.e2e.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Web e2e scenario: the single-line produced-files summary a finished turn -// ends with. Cold-seeds ten writes (zero model calls), then verifies the real -// assembled lane adapts from a coarse width budget and offers no folder -// handoff: chips open in the right Sidebar's text preview, which has no -// directory form, so the row shows nothing rather than a dead button. -import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-title' -import { - launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -const MODE = webSnapshotMode() -const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.meta.url)) -const SEED_ID = 'produced-files-web-e2e' -const DONE = 'PRODUCED_FILES_DONE' - -/** Ten varied names exercise estimated prefix selection and CSS shrinking. */ -const PRODUCED = [ - '关于我.md', - 'index.html', - 'long-generated-experience-specification-for-produced-files-overflow.md', - 'styles.css', - 'app.ts', - 'schema.json', - 'README.md', - 'preview.svg', - 'notes.txt', - 'manifest.yaml', -] as const - -/** Build one settled turn whose successful write calls carry ten locations. */ -function producedFixture(): string { - const session = Session.create(SessionId('produced-files-source')) - const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) - session.append('turn/start', { turn: 1 }) - const user = session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Create the site files.' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('session/title', { - title: 'Produced files overflow', messageSeqs: [user.seq], source: { kind: 'fallback' }, - }) - session.append('step/start', { turn: 1, step: 1 }) - const calls = PRODUCED.map((path, index) => ({ - path, - callId: ToolCallId(`produced-files-${String(index)}`), - args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }), - })) - session.append('assistant/message', { - stream: [], - turn: 1, - step: 1, - message: createAssistantMessage({ - content: calls.map(call => ({ - type: 'tool-call' as const, - id: call.callId, - name: 'write', - arguments: call.args, - })), - source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - }, { surfaceOp: 'append' }) - for (const call of calls) { - const source = session.append('tool/call', { - turn: 1, step: 1, callId: call.callId, name: 'write', arguments: call.args, - }) - session.append('tool/result', { - turn: 1, - step: 1, - message: createToolResultMessage({ - callId: call.callId, - content: [{ type: 'text', text: `Created ${call.path}` }], - isError: false, - }), - }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) - } - session.append('step/end', { turn: 1, step: 1 }) - session.append('step/start', { turn: 1, step: 2 }) - session.append('assistant/message', { - stream: [], - turn: 1, - step: 2, - message: createAssistantMessage({ - content: [{ type: 'text', text: `Created the site.\n\n${DONE}` }], - source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 2 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - - return [ - JSON.stringify({ - type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', - createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, - }), - ...session.snapshotEvents().map(event => JSON.stringify({ - ...event, time: eventTimeOrigin + event.seq * 1_000, - })), - '', - ].join('\n') -} - -describe('web e2e: a finished turn ends with the files it produced', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) - await seedSession(scaffold, producedFixture(), SEED_ID) - browser = await chromium.launch() - page = await newEnglishPage(browser) - // Keep the responsive sidebar available while selecting the cold seed; - // the assertion itself narrows the conversation after navigation. - await page.setViewportSize({ width: 1800, height: 900 }) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it.skipIf(MODE === 'record')('adapts a ten-file summary without leaving one line', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() - - await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) - const row = page.locator('[data-produced-files-row]') - await row.waitFor({ timeout: 15_000 }) - const chips = row.getByRole('button') - await expect.poll(() => chips.count()).toBe(6) - await expect.poll(() => row.getByText('+ 4 files', { exact: true }).isVisible()).toBe(true) - - await page.setViewportSize({ width: 750, height: 900 }) - await page.evaluate(async () => { await document.fonts.ready }) - await page.waitForFunction(() => { - const frame = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]') - if (frame === null) return false - const tracks = getComputedStyle(frame).gridTemplateColumns.split(' ').map(Number.parseFloat) - // The responsive sidebar's settled collapsed track is 56px. - return tracks[0] === 56 && tracks.at(-1) === 0 - && frame.getAnimations().every(animation => - animation.playState === 'finished' || animation.playState === 'idle') - }, undefined, { timeout: 10_000 }) - await expect.poll(() => chips.count()).toBe(4) - const laneWidth = await row.evaluate(element => element.clientWidth) - // Keep font-metric differences away from the 479px and 583px container-query edges. - expect(laneWidth).toBeGreaterThan(503) - expect(laneWidth).toBeLessThan(559) - expect(await chips.nth(0).innerText()).toBe('关于我.md') - expect(await chips.nth(1).innerText()).toBe('index.html') - expect(await chips.nth(3).innerText()).toBe('styles.css') - await expect.poll(() => row.getByText('+ 6 files', { exact: true }).isVisible()).toBe(true) - // Chips open in the right Sidebar's text preview, and a directory is not - // something that preview can show, so the row offers no folder action. - expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0) - expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1) - - const turnSpacing = await page.evaluate((done) => { - const requiredElement = (value: T | null | undefined, name: string): T => { - if (value === null || value === undefined) throw new Error(`produced-file layout is missing ${name}`) - return value - } - const answer = requiredElement( - [...document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] - .find(element => element.textContent?.includes(done)), - 'final answer', - ) - const producedRow = requiredElement( - document.querySelector('[data-produced-files-row]'), - 'produced row', - ) - const producedRoot = requiredElement(producedRow.parentElement?.parentElement, 'produced root') - const turnTail = requiredElement(producedRoot.closest('[data-turn-tail]'), 'turn tail') - const actions = requiredElement( - turnTail.querySelector('button[aria-label="Copy"]')?.parentElement, - 'action row', - ) - const answerRect = answer.getBoundingClientRect() - const producedRect = producedRoot.getBoundingClientRect() - const actionsRect = actions.getBoundingClientRect() - return { - answerToProduced: producedRect.top - answerRect.bottom, - producedToActions: actionsRect.top - producedRect.bottom, - } - }, DONE) - expect(turnSpacing.answerToProduced).toBeCloseTo(20, 1) - expect(turnSpacing.producedToActions).toBeCloseTo(20, 1) - - const tops = await row.locator(':scope > *:visible').evaluateAll(elements => - elements.map(element => element.getBoundingClientRect().top)) - expect(new Set(tops.map(top => Math.round(top))).size).toBe(1) - const geometry = await row.evaluate(element => ({ - clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, - })) - expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth) - - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }, 90_000) -}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index d1586408fd..1cad46d4fc 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -64,6 +64,7 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' +import { DEFAULT_SHADOW_EXCLUDES } from '@deepseek-ai/dsh-workspace-changes' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, RetryPolicyConfig, StreamChunk, @@ -596,6 +597,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise + +## `@deepseek-ai/dsh-workspace-changes` + +Requires: `subprocess` + +```ts config-catalog +/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */ +export interface Config { + /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Milliseconds one git command may run before the turn's record is abandoned. */ + timeoutMs: number + /** Bytes of git output retained per command; a larger diff listing abandons the record. */ + outputMaxBytes: number + /** Maximum files carried by one event; `total` still reports the complete count. */ + maxFiles: number + /** `info/exclude` patterns for shadow repositories. */ + shadowExcludes: string[] +} +``` + +Source: [`packages/fs/workspace-changes/src/index.ts:35`](../packages/fs/workspace-changes/src/index.ts) + ## Loadable plugins with no config These load from a `cordis.yml` entry with no `config:` block; they declare no configuration API. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dca9c956c9..01054e3dda 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3440,6 +3440,30 @@ export interface Config { 来源:[`packages/workflow/workflow-worker-thread/src/index.ts:32`](../packages/workflow/workflow-worker-thread/src/index.ts) + + +## `@deepseek-ai/dsh-workspace-changes` + +依赖: `subprocess` + +```ts config-catalog +/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */ +export interface Config { + /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Milliseconds one git command may run before the turn's record is abandoned. */ + timeoutMs: number + /** Bytes of git output retained per command; a larger diff listing abandons the record. */ + outputMaxBytes: number + /** Maximum files carried by one event; `total` still reports the complete count. */ + maxFiles: number + /** `info/exclude` patterns for shadow repositories. */ + shadowExcludes: string[] +} +``` + +来源: [`packages/fs/workspace-changes/src/index.ts:35`](../packages/fs/workspace-changes/src/index.ts) + ## 无配置的可加载插件 这些插件通过 `cordis.yml` 中不含 `config:` 块的条目加载;它们未声明任何配置接口。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0bd8fecb7c..884084e827 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 31bcff64bdbd4caa24124f196beef86233f22538 -event-producer-consumer.zh.md: 239149009d0ade0fa2869989e32d2c41fad46c83 +event-producer-consumer.md: 180144f94eb1ce1fa538dc98c813c6c2b883c288 +event-producer-consumer.zh.md: 300a2fe2605f2451052d24c43e6c2c50b06aa996 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 31bcff64bd..180144f94e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/fs/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -49,8 +49,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/fs/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/fs/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -64,7 +64,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:155`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/fs/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 239149009d..300a2fe260 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,7 +23,7 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/fs/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -51,8 +51,8 @@ | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/fs/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/fs/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -66,7 +66,7 @@ | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:155`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/fs/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 409b921f38..77d8acc942 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: 9e1f873b5690838c8a680ddf4ee3dd9c009cec80 -module-graph.zh.md: 81abb4bb4959ad72901bfda270e08dccd237c256 +module-graph.md: cd584a91ad18a3cb76dfb75212732fd86bb972c8 +module-graph.zh.md: 9727a6e78f15d08ce0f32545242c6e0dd957cebe diff --git a/docs/module-graph.md b/docs/module-graph.md index 9e1f873b56..cd584a91ad 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -58,6 +58,7 @@ flowchart TD pkg_tool_fs_search["tool-fs-search"] pkg_tool_present["tool-present"] pkg_tool_str_replace_editor["tool-str-replace-editor"] + pkg_workspace_changes["workspace-changes"] end subgraph group_skill["packages/skill"] pkg_skill["skill"] @@ -747,6 +748,11 @@ flowchart TD pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy pkg_tool_str_replace_editor --> pkg_tools + pkg_workspace_changes --> pkg_agent + pkg_workspace_changes --> pkg_home_paths + pkg_workspace_changes --> pkg_session + pkg_workspace_changes --> pkg_subprocess + pkg_workspace_changes --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill @@ -1387,6 +1393,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | +| [`workspace-changes`](../packages/fs/workspace-changes) | `fs` | [`agent`](../packages/core/agent), [`home-paths`](../packages/util/home-paths), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 81abb4bb49..9727a6e78f 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -60,6 +60,7 @@ flowchart TD pkg_tool_fs_search["tool-fs-search"] pkg_tool_present["tool-present"] pkg_tool_str_replace_editor["tool-str-replace-editor"] + pkg_workspace_changes["workspace-changes"] end subgraph group_skill["packages/skill"] pkg_skill["skill"] @@ -749,6 +750,11 @@ flowchart TD pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy pkg_tool_str_replace_editor --> pkg_tools + pkg_workspace_changes --> pkg_agent + pkg_workspace_changes --> pkg_home_paths + pkg_workspace_changes --> pkg_session + pkg_workspace_changes --> pkg_subprocess + pkg_workspace_changes --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill @@ -1389,6 +1395,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | +| [`workspace-changes`](../packages/fs/workspace-changes) | `fs` | [`agent`](../packages/core/agent), [`home-paths`](../packages/util/home-paths), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 3632731419..04e1d78c76 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: 7d6c9649628596b04dbe1d75c564a7f5965be940 -persistence-catalog.zh.md: c1736a1f5654f82ea9995b6389630648fc8185eb +persistence-catalog.md: 3afb94a67096e005f9222e5ddff7470ccdc2c8d2 +persistence-catalog.zh.md: be01524413efa29888206cdf93a79b98ef97814b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7d6c964962..3afb94a670 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1146,3 +1146,16 @@ Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/ ``` Source: [`packages/web/web-search-deepseek/src/provider.ts:83`](../packages/web/web-search-deepseek/src/provider.ts) + +### `workspace/*` + + + +#### `workspace/changes` — log-only + +```ts persistence-catalog +/** Files changed by a completed top-level turn; the latest event for one turn replaces earlier ones. */ +'workspace/changes': WorkspaceChangesData +``` + +Source: [`packages/fs/workspace-changes/src/types.ts:36`](../packages/fs/workspace-changes/src/types.ts) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c1736a1f56..be01524413 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1148,3 +1148,16 @@ export type SessionEvent = { ``` 来源:[`packages/web/web-search-deepseek/src/provider.ts:83`](../packages/web/web-search-deepseek/src/provider.ts) + +### `workspace/*` + + + +#### `workspace/changes` — 仅日志 + +```ts persistence-catalog +/** Files changed by a completed top-level turn; the latest event for one turn replaces earlier ones. */ +'workspace/changes': WorkspaceChangesData +``` + +Source: [`packages/fs/workspace-changes/src/types.ts:36`](../packages/fs/workspace-changes/src/types.ts) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index cf4603031f..bcffd889f1 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -274,11 +274,17 @@ - id: ui-workflow-run name: '@deepseek-ai/dsh-client-ui-workflow-run' - # Turn tail: the produced-files row under each closing assistant message. - # Remove this entry to turn the surface off; the tail hole renders empty. + # Turn tail: the changed-files card and delivery cards under each closing + # assistant message. Remove this entry to turn the surface off; the tail + # hole renders empty. - id: ui-deliverables name: '@deepseek-ai/dsh-client-ui-deliverables' + # Records each top-level turn's changed files from git working-tree + # snapshots; the changed-files card above renders its events. + - id: workspace-changes + name: '@deepseek-ai/dsh-workspace-changes' + - id: ui-workspace name: '@deepseek-ai/dsh-client-ui-workspace' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3498cb6761..2545c3fcef 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", + "@deepseek-ai/dsh-workspace-changes": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index fe288b59a1..a45c62f6f1 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: 0894d4e0ef5e45876a58abea40cdad840e7dfacd -README.zh.md: 8183ef33ca7e08bec2533eb2e21486809c94cedd +README.md: b2cbec2db3ee1bd9907bbaad95061373fd13ed63 +README.zh.md: 58fccb9a54578a9570510d8eadb3eb5da70ce8e3 diff --git a/packages/client/README.md b/packages/client/README.md index 0894d4e0ef..b2cbec2db3 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -70,7 +70,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — | | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | -| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | +| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card, delivery cards, and clickable final-response file references | — | | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 8183ef33ca..58fccb9a54 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -70,7 +70,7 @@ kind: "package-group" | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — | | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — | -| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | +| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片、交付文件卡片与可点击的最终响应文件引用 | — | | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index f359ce5e69..18d9a29f46 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/ui-deliverables/README.md -README.md: 5ae62206bed36e611635511f0ad3472ea4e98107 -README.zh.md: 3d154d9c4239f119624c74e219980c0be3ab81c0 +README.md: bb15db710decbc16a0974daf6bed555c3fb10815 +README.zh.md: a7db3227b5765eb303caf80e9337ee6b6d1354d7 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 5ae62206be..bb15db710d 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -1,5 +1,5 @@ --- -description: "Produced-files and clickable file references for the Web GUI: the deliverables row a finished turn ends with, and inline-code links in the closing prose; for users and maintainers of the deliverables experience." +description: "Changed files, deliveries, and clickable file references for the Web GUI: the changed-files card and delivery cards a finished turn ends with, and inline-code links in the closing prose; for users and maintainers of the deliverables experience." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders the deliverables row a finished turn ends with — the files the mutation tools created or modified — and links matching inline-code references in the closing prose, so a mentioned file opens in the right Sidebar. The linked paths come from successful mutations and explicit deliveries, never from the closing prose — a produced file is listed whether or not the model remembered to name it. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, row, and prose links together. +This package renders the changed-files card a finished turn ends with — the files the turn changed, with the line counts the Host recorded from git snapshots — plus cards for explicitly delivered files, and links matching inline-code references in the closing prose so a mentioned file opens in the right Sidebar. Listed and linked paths come from the recorded summary, successful mutations, and explicit deliveries, never from the prose. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, cards, and prose links together. ## Table of Contents @@ -25,7 +25,7 @@ This package renders the deliverables row a finished turn ends with — the file ## Use this package -Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar as a text-preview tab, with relative paths resolved against the session cwd. The row offers no folder action: the Sidebar has no directory form, so an omitted-file remainder is a label only. +Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../fs/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a recorded summary — no git on the Host, or the plugin composed out — the card is absent and only deliveries and prose links remain. ### Explicit deliveries @@ -34,9 +34,9 @@ The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. -### The row +### The changed-files card -The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the row, and the action footer starts 20px below the last file section. The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. +The card renders the turn's latest `workspace/changes` event: its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, or “binary” for a binary file. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager and each row opens its file in the default application, both through authenticated Host actions that share the delivery cards' pending, acknowledged, and retryable-error states in place of the counts. Without a desktop the header is a plain label and rows preview their files in the right Sidebar. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. ### Inline-code links @@ -50,9 +50,9 @@ The closing prose links produced or delivered paths: an inline-code token resolv
Implementation internals — click to expand -The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. +The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's latest validated `workspace/changes` event into `DeliverablesTurnData.changes`, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host reads the viewed Session header with the declaration and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory. The Host reads the viewed Session header with the declaration or summary and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
@@ -61,10 +61,12 @@ Native opening uses an authenticated POST addressed by the viewed Session, event ## Further Exploration -Read these pages when the deliverables surface is not enough. They move from the row to the turn-tail hole and the decisions behind the vocabulary. +Read these pages when the deliverables surface is not enough. They move from the card to the Host recorder, the turn-tail hole, and the decisions behind the vocabulary. +- [workspace-changes](../../fs/workspace-changes/README.md) — the Host plugin that records the summary the card renders. - [ui-conversation](../ui-conversation/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose. -- [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the produced-files row; its Host open path is superseded by the [right Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md). +- [Turn changed-files card](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the decision behind git-recorded summaries replacing the mutation-call row. +- [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the earlier produced-files row; its Host open path is superseded by the [right Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md). - [Inline file mentions](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md) — the decision behind clickable mentions in the closing prose. - [Client package map](../README.md) — adjacent browser UI packages. @@ -95,9 +97,10 @@ The section is static at first-party order 9000 for the lifetime of the package These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog. - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. -- **Terminal-created files require explicit delivery** — call `present` to make them available as delivery cards and clickable references. +- **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. -- **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. +- **Folder opening needs a Host desktop** — without one the card header is inert and rows fall back to the right Sidebar's text preview, which shows files only. +- **Files outside the workspace open by absolute path only** — the recorded path is the Host path at recording time; a moved workspace or a different viewing Session cannot relocate it. ### Dev Note diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 3d154d9c42..a7db3227b5 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的产出文件与可点击文件引用:已完成轮次末尾的产出文件行,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。" +description: "Web GUI 的改动文件、交付文件与可点击文件引用:已完成轮次末尾的改动文件卡片与交付文件卡片,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包渲染已完成轮次末尾的产出文件行——列出修改工具创建或修改的文件——并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。链接路径来自成功的文件修改与显式交付,而非收尾正文——无论模型是否记得点名,产出文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、文件行与正文链接。 +本包渲染已完成轮次末尾的改动文件卡片——列出本轮改动的文件及 Host 从 git 快照记录的增删行数——以及显式交付文件的卡片,并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。列出与链接的路径来自记录的改动摘要、成功的文件修改与显式交付,而非收尾正文——无论模型是否记得点名,改动文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、卡片与正文链接。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略项只显示为标签。 +与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../fs/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有记录的摘要时——Host 上没有 git,或该插件被组合出去——卡片不出现,只保留交付卡片与正文链接。 ### 显式交付 @@ -34,9 +34,9 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 -### 该行 +### 改动文件卡片 -“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于该行下方 16px,操作页脚位于最后一个文件区块下方 20px。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用省略号截断,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 +卡片渲染本轮最新的 `workspace/changes` 事件:标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。Host 有桌面时,标题在文件管理器中打开包含所列文件的最深工作区文件夹,每一行用默认应用打开该文件,两者都通过经过认证的 Host 操作完成,并在计数位置复用交付卡片的等待、已确认与可重试错误状态。没有桌面时标题是普通标签,行改为在右侧 Sidebar 中预览文件。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 ### 行内代码链接 @@ -50,9 +50,9 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 +Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 事件折叠进 `DeliverablesTurnData.changes`,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明。Host 读取声明及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录。Host 读取声明或摘要及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
@@ -61,10 +61,12 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, ## 进一步探索 -当产出物面不够用时阅读以下页面。它们从该行进入 turn-tail 洞与词表背后的决策。 +当产出物面不够用时阅读以下页面。它们从卡片进入 Host 记录器、turn-tail 洞与词表背后的决策。 +- [workspace-changes](../../fs/workspace-changes/README.zh.md)——记录卡片所渲染摘要的 Host 插件。 - [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。 -- [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。 +- [本轮改动文件卡片](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——用 git 记录的摘要取代修改调用行背后的决策。 +- [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——早先产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。 - [行内文件提及](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md)——收尾正文可点击提及背后的决策。 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 @@ -95,9 +97,10 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 -- **终端创建的文件需要显式交付**——调用 `present` 声明后才会显示交付卡片和可点击引用。 +- **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 -- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。 +- **打开文件夹需要 Host 桌面**——没有桌面时卡片标题不可点击,行退回到右侧 Sidebar 的文本预览,该预览仅支持文件。 +- **工作区外的文件只按绝对路径打开**——记录的路径是记录时的 Host 路径;工作区移动或换一个查看 Session 都无法重新定位它。 ### 开发备注 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 676e3569fd..f68152c8f4 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", - "description": "Produced-files turn tail and clickable final-response file references for Web", + "description": "Changed-files card, delivery cards, and clickable final-response file references for Web", "version": "0.1.5-rc.2", "publishConfig": { "access": "public" @@ -67,6 +67,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-tool-present": "workspace:^", + "@deepseek-ai/dsh-workspace-changes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts new file mode 100644 index 0000000000..b86bee4915 --- /dev/null +++ b/packages/client/ui-deliverables/src/changes.ts @@ -0,0 +1,43 @@ +/** Validate recorded workspace changes and address their native-open actions. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceChangedFile, WorkspaceChangesData } from '@deepseek-ai/dsh-workspace-changes/types' + +/** Authenticated POST route for opening a changed file, or the changed files' common folder, on the Host desktop. */ +export const CHANGES_OPEN_PATH = '/api/changes.open' + +/** + * Validate one changed-file record read from a Session log. + * @param value - decoded durable data. + * @returns whether the record carries a path, a display path, and line counts. + */ +export function isChangedFile(value: unknown): value is WorkspaceChangedFile { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { path, display, added, deleted, binary } = value as Record + return typeof path === 'string' && path.length > 0 && typeof display === 'string' && display.length > 0 + && Number.isSafeInteger(added) && Number.isSafeInteger(deleted) && (binary === undefined || binary === true) +} + +/** + * Validate a change summary before reading its turn or files. + * @param value - decoded durable event data. + * @returns whether the event identifies a turn, a complete file list, and the total count. + */ +export function isChangesData(value: unknown): value is WorkspaceChangesData { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { turn, files, total } = value as Record + return typeof turn === 'number' && Number.isSafeInteger(turn) && turn >= 1 + && Number.isSafeInteger(total) && Array.isArray(files) && files.every(isChangedFile) +} + +/** + * Build authenticated coordinates for a changed file or the changed files' common folder. + * @param sessionId - owning Session. + * @param seq - workspace/changes event sequence. + * @param index - original index in the event's files array, or null for the common folder. + * @returns same-origin action URL. + */ +export function changedFileUrl(sessionId: SessionId, seq: number, index: number | null): string { + const query = new URLSearchParams({ sessionId, seq: String(seq) }) + if (index !== null) query.set('index', String(index)) + return `${CHANGES_OPEN_PATH}?${query}` +} diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css new file mode 100644 index 0000000000..4ce481e979 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -0,0 +1,26 @@ +/** Changed-files card: filled header, code-font rows with line counts, and a bottom fold control. */ +.card { --changes-fill: var(--dsw-static-neutral-50); --changes-hover: var(--dsw-static-neutral-100); display: flex; flex-direction: column; min-width: 0; margin-top: 4px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 16px; color: var(--dsw-alias-label-primary); } +:global(body[data-ds-dark-theme]) .card { --changes-fill: var(--dsw-static-neutral-850); --changes-hover: var(--dsw-static-neutral-800); } +.header { display: flex; align-items: center; gap: 12px; box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 12px 16px; border: 0; background: var(--changes-fill); color: inherit; font: inherit; text-align: left; } +button.header { cursor: pointer; transition: background-color 120ms ease; } +button.header:hover:not(:disabled), button.header:focus-visible { background: var(--changes-hover); } +button.header:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } +button.header:disabled { cursor: progress; } +.tile { display: grid; flex: none; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--dsw-alias-link); color: var(--dsw-static-neutral-00); } +.titles { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } +.title { overflow: hidden; font-size: 14px; font-weight: 500; line-height: 22px; text-overflow: ellipsis; white-space: nowrap; } +.stat { display: inline-flex; gap: 6px; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); } +.stat[data-error='true'], .counts[data-error='true'] { color: var(--dsw-alias-state-error-primary); } +.added { color: var(--dsw-alias-state-success-primary); } +.deleted { color: var(--dsw-alias-state-error-primary); } +.list { margin: 0; padding: 4px 0; list-style: none; border-top: 0.5px solid var(--dsw-alias-border-l1); } +.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 6px 16px; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 18px; text-align: left; } +.row:hover:not(:disabled), .row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } +.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3); } +.row:disabled { cursor: progress; } +.path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.counts { display: inline-flex; flex: none; gap: 6px; white-space: nowrap; color: var(--dsw-alias-label-tertiary); } +.toggle { display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; width: 100%; margin: 0; padding: 8px 16px; border: 0; border-top: 0.5px solid var(--dsw-alias-border-l1); background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; text-align: left; } +.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); } +.toggle svg { flex: none; width: 14px; height: 14px; } +@media (pointer: coarse) { .row, .toggle { min-height: 44px; } } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx new file mode 100644 index 0000000000..58342c6263 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -0,0 +1,113 @@ +/** The changed-files card: a folder-opening header, per-file line counts, and a three-row fold. */ +import { useState } from 'react' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' +import { IconChevronDownOutline14, IconChevronUpOutline14, IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' +import { changedFileUrl } from '../changes.ts' +import type { PresentedHost } from '../presented.ts' +import type { PresentedOpenPhase } from './present-open.ts' +import type { ChangesTurnData } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './ChangedFiles.module.css' + +/** Rows shown before the fold; the design's summary height for a closing message. */ +const COLLAPSED_ROWS = 3 + +const GROUPED = new Intl.NumberFormat('en-US') + +/** Row copy for an open gesture; the changed-files card never reveals, so only open phases occur. */ +function rowStatus(phase: PresentedOpenPhase | undefined): { key: 'presented.opening' | 'presented.opened' | 'presented.error' | 'presented.nativeUnavailable'; error: boolean } | undefined { + switch (phase) { + case undefined: return undefined + case 'opening': return { key: 'presented.opening', error: false } + case 'error': return { key: 'presented.error', error: true } + case 'nativeUnavailable': return { key: 'presented.nativeUnavailable', error: true } + default: return { key: 'presented.opened', error: false } + } +} + +function totals(files: readonly WorkspaceChangedFile[]): { added: number; deleted: number } { + let added = 0 + let deleted = 0 + for (const file of files) { + added += file.added + deleted += file.deleted + } + return { added, deleted } +} + +/** + * Render one turn's changed files. Rows open files in the Host's default + * application when a desktop is available and otherwise preview them in the + * right Sidebar; the header opens the files' common folder only with a desktop. + * @param props - the recorded summary, Host capabilities, gesture status, openers, and localized copy. + * @returns the card. + */ +export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openFile, t }: { + changes: ChangesTurnData + cwd: string | undefined + sessionId: SessionId + host: PresentedHost | null + phases: Record + onOpen: (index: number | null) => void + openFile: (path: string) => void +} & PropsLocale) { + const [expanded, setExpanded] = useState(false) + const native = host !== null && host.available + const foldable = changes.files.length > COLLAPSED_ROWS + const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files + const sum = totals(changes.files) + const folderPhase = phases[changedFileUrl(sessionId, changes.seq, null)] + const folderStatus = folderPhase === 'opening' ? t('changes.folderOpening') + : folderPhase === 'opened' ? t('changes.folderOpened') + : folderPhase === undefined ? undefined : t('changes.folderError') + const summary = <> + + + {t('changes.title', { count: String(changes.total) })} + + {folderStatus ?? <> + {t('changes.added', { count: GROUPED.format(sum.added) })} + {t('changes.deleted', { count: GROUPED.format(sum.deleted) })} + } + + + + return
+ {native + ? + :
{summary}
} +
    + {rows.map((file, index) => { + const phase = phases[changedFileUrl(sessionId, changes.seq, index)] + const status = rowStatus(phase) + return
  • + +
  • + })} +
+ {foldable && } +
+} diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index c78725bdcb..c772e6d01c 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -2,7 +2,7 @@ .root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 4px; } /* The turn-tail gap alone separates adjacent file sections. The default top margin remains when deliveries are the first file section after prose. */ -.root[data-after-produced-files='true'] { margin-top: 0; } +.root[data-after-changes='true'] { margin-top: 0; } :global(body[data-ds-dark-theme]) .root { --deliverable-fill: var(--dsw-static-neutral-850); --deliverable-hover: var(--dsw-static-neutral-800); } .hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); } .presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; min-width: 0; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 6099cfdf72..7e1f9a1238 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,18 +1,18 @@ -/** Existing changed-file chips and explicitly declared files for a closing turn. */ +/** The changed-files card and explicitly declared files for a closing turn. */ import { useEffect, useState } from 'react' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { Button, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' -import { ProducedFiles } from './ProducedFiles.tsx' -import { presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' +import { ChangedFiles } from './ChangedFiles.tsx' +import { changesForClosing, presentedForClosing, type ChangesTurnData, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import { presentedFileUrl } from '../presented.ts' import { PresentedFileCard } from './PresentedFileCard.tsx' import css from './Deliverables.module.css' -interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] } +interface DeliverablesMatch { changes: ChangesTurnData | null; presented: readonly PresentedPath[] } const COLLAPSED_PRESENTED_COUNT = 4 @@ -24,25 +24,26 @@ export interface DeliverablesInjected { } reloadPresentedHost: PresentedOpenController['loadHost'] openPresented: PresentedOpenController['open'] + openChanged: PresentedOpenController['openChanged'] } /** - * Claim turns containing modified paths or declared files. + * Claim turns with a recorded change summary or declared files. * @param owner - closing turn. - * @returns matched files, or null for an empty turn. + * @returns matched changes and deliveries, or null for a turn with neither. */ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch | null { - const produced = selectProducedFiles(owner) ?? [] + const changes = changesForClosing(owner) const presented = presentedForClosing(owner) - return produced.length + presented.length === 0 ? null : { produced, presented } + return changes === null && presented.length === 0 ? null : { changes, presented } } /** - * Render workspace file actions and default-application buttons for declared files. + * Render the changed-files card and default-application buttons for declared files. * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { +export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, openChanged, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { matched: DeliverablesMatch } & PropsLocale & Pick & Pick & InjectFace) { const [expanded, setExpanded] = useState(false) @@ -54,13 +55,16 @@ export function Deliverables({ matched, openFile, t, sessionId, useSessions, ope ? matched.presented.slice(0, COLLAPSED_PRESENTED_COUNT) : matched.presented useEffect(() => { - if (matched.presented.length > 0 && host === null) void reloadPresentedHost() - }, [matched.presented.length, host, reloadPresentedHost]) + if (host === null) void reloadPresentedHost() + }, [host, reloadPresentedHost]) + const { changes } = matched return <> - {matched.produced.length > 0 && } + {changes !== null && { void openChanged(sessionId, changes.seq, index) }} />} {matched.presented.length > 0 &&
0 || undefined} + data-after-changes={matched.changes !== null || undefined} > {host === 'error' &&
{t('presented.hostError')} diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.module.css b/packages/client/ui-deliverables/src/client/ProducedFiles.module.css deleted file mode 100644 index 8bccbf3b5c..0000000000 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.module.css +++ /dev/null @@ -1,159 +0,0 @@ -/* Turn-tail produced-files summary with an optional native-folder action. */ - -.root { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - align-items: start; - column-gap: 8px; - margin-top: 4px; - font-size: 13px; - line-height: 22px; -} - -.label { - grid-column: 1; - grid-row: 1; - color: var(--dsw-alias-label-tertiary); -} - -.lane { - --produced-file-gap: 8px; - - grid-column: 2; - grid-row: 1; - display: grid; - row-gap: 6px; - min-width: 0; - container-type: inline-size; -} - -.row { - display: flex; - flex-wrap: nowrap; - align-items: center; - gap: var(--produced-file-gap); - min-width: 0; - overflow: hidden; -} - -/* File names render at natural width and only shrink (with ellipsis) when - the row truly runs out of space; the container-query bands still budget - 96px per chip when deciding how many chips to show. Same link language as - markdown anchors and file mentions: link-blue at rest with no underline, - dotted underline on hover; the leading glyph names the file's category - and rides the link color. */ -.file { - box-sizing: border-box; - display: inline-flex; - align-items: center; - gap: 5px; - flex: 0 1 auto; - min-width: 0; - margin: 0; - padding: 0; - border: none; - border-radius: 4px; - background: none; - color: var(--dsw-alias-link); - font: inherit; - font-weight: 500; - text-decoration: none; - cursor: pointer; -} - -/* Flex centering tracks box centers, but the 22px text box carries its - glyphs below center (the baseline sits low in the leading); the nudge - drops the glyph onto the text's visual center. */ -.fileIcon { - flex: none; - width: 1.1em; - height: 1.1em; - position: relative; - top: 1.2px; -} - -.fileName { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.file:hover, -.file:focus-visible { - text-decoration: underline dotted; - text-underline-offset: 3px; -} - -.file:focus-visible { - outline: none; - box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3); -} - -/* Overflow count: the row never silently drops files it did not show. */ -.more { - flex: 0 0 auto; - display: none; - white-space: nowrap; - color: var(--dsw-alias-label-tertiary); -} - -.more[data-shown='6'] { - display: inline; -} - -/* Each band budgets 96px per chip, 8px gaps, and 64px for the remainder. */ -@container (max-width: 687px) { - .file:nth-of-type(6), - .more[data-shown='6'] { - display: none; - } - - .more[data-shown='5'] { - display: inline; - } -} - -@container (max-width: 583px) { - .file:nth-of-type(5), - .more[data-shown='5'] { - display: none; - } - - .more[data-shown='4'] { - display: inline; - } -} - -@container (max-width: 479px) { - .file:nth-of-type(4), - .more[data-shown='4'] { - display: none; - } - - .more[data-shown='3'] { - display: inline; - } -} - -@container (max-width: 375px) { - .file:nth-of-type(3), - .more[data-shown='3'] { - display: none; - } - - .more[data-shown='2'] { - display: inline; - } -} - -@container (max-width: 271px) { - .file:nth-of-type(2), - .more[data-shown='2'] { - display: none; - } - - .more[data-shown='1'] { - display: inline; - } -} diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx deleted file mode 100644 index 3908138708..0000000000 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { LinkIcon, classifyLinkPath } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { basename } from './turn-deliverables.ts' -import type { NS } from './locales.ts' -import css from './ProducedFiles.module.css' - -/** Maximum number of file chips rendered before the remainder counter. */ -const SHOWN_LIMIT = 6 - -/** Matched paths, the opener, and the locale seat. */ -export type ProducedFilesProps = Pick & { - matched: readonly string[] -} & PropsLocale - -function moreLabel(t: ProducedFilesProps['t'], count: number): string { - return count === 1 ? t('produced.moreOne') : t('produced.more', { count: String(count) }) -} - -/** - * Render one turn's produced files as openable chips. - * @param props - selector-matched paths, the chat view's file opener, and the locale seat. - * @returns The produced-files row. - */ -export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) { - const shown = paths.slice(0, SHOWN_LIMIT) - return ( -
- {t('produced.label')} -
-
- {shown.map(path => ( - - ))} - {shown.map((_, index) => { - const shownCount = index + 1 - const remainder = paths.length - shownCount - if (remainder <= 0) return null - return ( - - {moreLabel(t, remainder)} - - ) - })} -
-
-
- ) -} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 1978ca934e..42a3e5a0d5 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -1,11 +1,11 @@ /** - * Deliverables plugin, browser half: registers the produced-files row into - * the chat view's turn-tail chain, and provides the `chatFileMentions` - * service that links inline-code mentions of produced or delivered files in the closing - * prose. All policy lives here — the supported mutation calls, mention - * matching, chip cap, and copy — so - * composing this plugin out of cordis.yml removes both surfaces entirely; - * the owning view renders an empty chain and inert prose at zero cost. + * Deliverables plugin, browser half: registers the changed-files card and + * delivery cards into the chat view's turn-tail chain, and provides the + * `chatFileMentions` service that links inline-code mentions of produced or + * delivered files in the closing prose. All policy lives here — the supported + * mutation calls, mention matching, row cap, and copy — so composing this + * plugin out of cordis.yml removes both surfaces entirely; the owning view + * renders an empty chain and inert prose at zero cost. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' @@ -24,14 +24,11 @@ import { declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Produced-files row copy. */ + /** Changed-files card, delivery card, and file-mention copy. */ 'deliverables': DeliverablesKey } } -export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' -export { producedForClosing } from './turn-deliverables.ts' - /** Required services for the tail-slot registration and its dictionaries. */ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session'] @@ -55,6 +52,7 @@ export function apply(ctx: ClientContext): void { hooks: { presentedOpen: opener.state, presentedHost: opener.host }, reloadPresentedHost: () => opener.loadHost(), openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), + openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), }), }, Deliverables), ) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 5d51aec22f..88c846512c 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -38,10 +38,19 @@ export const zh = { 'row.error': '交付失败', 'row.stopped': '已中断', 'row.inspect': '查看调用', - 'produced.label': '本轮文件改动', - 'produced.moreOne': '+ 1 个文件', - 'produced.more': '+ {count} 个文件', - 'produced.open': '打开 {name}', + 'changes.title': '已编辑 {count} 个文件', + 'changes.added': '+{count}', + 'changes.deleted': '-{count}', + 'changes.binary': '二进制', + 'changes.openFolder': '打开改动文件所在的文件夹', + 'changes.openFile': '用默认应用打开 {name}', + 'changes.all': '全部 {count} 个文件', + 'changes.expandAria': '展开全部 {count} 个改动文件', + 'changes.collapse': '收起', + 'changes.collapseAria': '收起改动文件列表', + 'changes.folderOpening': '正在打开文件夹…', + 'changes.folderOpened': '已请求打开文件夹', + 'changes.folderError': '无法打开文件夹,点击重试', } /** English dictionary (same key set). */ @@ -79,10 +88,19 @@ export const en: Record = { 'row.error': 'Delivery failed', 'row.stopped': 'Interrupted', 'row.inspect': 'Inspect call', - 'produced.label': 'Files changed', - 'produced.moreOne': '+ 1 file', - 'produced.more': '+ {count} files', - 'produced.open': 'Open {name}', + 'changes.title': 'Edited {count} files', + 'changes.added': '+{count}', + 'changes.deleted': '-{count}', + 'changes.binary': 'binary', + 'changes.openFolder': 'Open the folder containing the changed files', + 'changes.openFile': 'Open {name} in default app', + 'changes.all': 'All {count} files', + 'changes.expandAria': 'Show all {count} changed files', + 'changes.collapse': 'Collapse', + 'changes.collapseAria': 'Collapse changed files', + 'changes.folderOpening': 'Opening folder…', + 'changes.folderOpened': 'Requested opening folder', + 'changes.folderError': 'Could not open folder. Click to retry.', } /** Union of this namespace's dictionary keys. */ diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 8e66b380c0..8955cdfc71 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -1,6 +1,7 @@ -/** Shared native-open status for delivery cards and closing-message file mentions. */ +/** Shared native-open status for delivery cards, the changed-files card, and closing-message file mentions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { changedFileUrl } from '../changes.ts' import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ @@ -26,8 +27,23 @@ export class PresentedOpenController { * @param action - default application open or file-manager reveal. * @returns after the Host acknowledges opening or the error state is published. */ - async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise { - const url = presentedFileUrl(sessionId, seq, index) + open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise { + return this.openUrl(presentedFileUrl(sessionId, seq, index), action) + } + + /** + * Open one recorded changed file, or the changed files' common folder, in + * the Host's default application. + * @param sessionId - viewed Session. + * @param seq - durable workspace/changes event sequence. + * @param index - original file index within that event, or null for the common folder. + * @returns after the Host acknowledges opening or the error state is published. + */ + openChanged(sessionId: SessionId, seq: number, index: number | null): Promise { + return this.openUrl(changedFileUrl(sessionId, seq, index), 'open') + } + + private async openUrl(url: string, action: PresentedAction): Promise { const phase = this.state.getSnapshot()[url] if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 31c78d9e57..4427021c00 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -1,13 +1,16 @@ /** * Turn-scoped produced-file Definition and readers. Client-only and - * model-free: the vocabulary comes from successful first-party mutation - * calls, never presentation data or the closing prose. + * model-free: produced paths come from successful first-party mutation calls, + * changed files from the Host's recorded git summary, and deliveries from + * `present`; never from presentation data or the closing prose. */ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types' +import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' +import { isChangesData } from '../changes.ts' import { basename, isPresentedData, isPresentedFile } from '../presented.ts' /** A declared file with its authorized open coordinates. */ @@ -21,15 +24,23 @@ interface ProducedPath { readonly path: string } +/** The latest recorded change summary of one Turn with its open coordinates. */ +export interface ChangesTurnData { + readonly seq: number + readonly files: readonly WorkspaceChangedFile[] + readonly total: number +} + /** Immutable produced-file facts published against one Turn. */ export interface DeliverablesTurnData { readonly produced: readonly ProducedPath[] readonly presented?: readonly PresentedPath[] + readonly changes?: ChangesTurnData } declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationTurnDataMap { - /** Successful mutation paths accumulated in this Turn. */ + /** Successful mutation paths, recorded changed files, and deliveries accumulated in this Turn. */ deliverables: DeliverablesTurnData } } @@ -160,6 +171,7 @@ export const deliverablesDefinition: ConversationNodeDefinition { + if (match.event.type === 'workspace/changes') { + const { files, total } = match.event.data + return { ...context.state, changes: { seq: match.event.seq, files, total } } + } if (match.event.type === 'deliverables/presented') { const { files } = match.event.data const seq = match.event.seq @@ -204,16 +220,31 @@ export const deliverablesDefinition: ConversationNodeDefinition { - const task = handlePresentOpen(ctx, new Request(request, { - signal: AbortSignal.any([request.signal, lifetime.signal]), - })) - pending.add(task) - void task.then(() => { pending.delete(task) }, () => { pending.delete(task) }) - return task - }, - }) + for (const [path, handler] of [[PRESENT_OPEN_PATH, handlePresentOpen], [CHANGES_OPEN_PATH, handleChangesOpen]] as const) { + ctx.connection.fetch.register({ + path, + methods: ['POST'], + requestBody: 'buffered', + fetch: (request) => { + const task = handler(ctx, new Request(request, { + signal: AbortSignal.any([request.signal, lifetime.signal]), + })) + pending.add(task) + void task.then(() => { pending.delete(task) }, () => { pending.delete(task) }) + return task + }, + }) + } +} + +const NUMERIC = /^\d+$/ + +function coordinate(value: string | null): number | undefined { + return value !== null && NUMERIC.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : undefined +} + +/** Translate lookup and filesystem failures into the not-found or failure status the browser retries from. */ +function failureStatus(error: unknown): number { + const remote = remoteErrorOf(error) + const missing = remote?.code === 'session/not-found' || remote?.code === 'workspace-file/not-found' + || remote?.code === 'workspace-file/not-regular-file' || error instanceof Error && 'code' in error + && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND' + || error.code === 'ENOENT' || error.code === 'ENOTDIR') + return missing ? 404 : 500 +} + +/** + * Read the addressed event once the Host desktop is known to be available. + * @returns the event with its Session header, or the refusal to answer with. + */ +async function readTarget(ctx: Context, request: Request, id: string, seq: number): Promise> | Response> { + request.signal.throwIfAborted() + if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 }) + return ctx.sessionQuery.readEvent({ sessionId: id as SessionId, seq: seq as SessionSeq, before: 0, after: 0 }, request.signal) +} + +/** + * Open one verified Host path. A file is verified through the Session + * filesystem; a directory is verified by the Host filesystem mapping alone. + * @returns the HTTP status to answer with. + */ +async function openVerified(ctx: Context, request: Request, path: string, action: 'open' | 'reveal'): Promise { + const { fs } = ctx + const mapped = fs.processPathFromHostPath(path) + if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal: request.signal })) !== path) { + return new Response('Path has no verified Host path.', { status: 422 }) + } + request.signal.throwIfAborted() + await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal) + return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } }) } async function handlePresentOpen(ctx: Context, request: Request): Promise { @@ -46,40 +94,74 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise { + const query = new URL(request.url).searchParams + const id = query.get('sessionId') + const seq = coordinate(query.get('seq')) + const rawIndex = query.get('index') + const index = rawIndex === null ? null : coordinate(rawIndex) + if (!id || seq === undefined || index === undefined) return new Response('Invalid changed file coordinates.', { status: 400 }) + try { + const read = await readTarget(ctx, request, id, seq) + if (read instanceof Response) return read + const { target, session } = read + const changes = target.type === 'workspace/changes' && isChangesData(target.data) ? target.data : undefined + if (changes === undefined) return new Response('Changed files not found in this Session event.', { status: 404 }) + const workspaceRoot = session.cwd ?? ctx.sandboxPolicy.workspaceRoot + request.signal.throwIfAborted() + if (index === null) { + const folder = commonChangedFolder(workspaceRoot, changes.files) + const target = await ctx.fs.resolve(folder, { signal: request.signal }) + if ((await ctx.fs.stat(target, request.signal))?.type !== 'directory') return new Response('Changed files folder unavailable.', { status: 404 }) + return await openVerified(ctx, request, ctx.fs.processPath(target), 'open') + } + const file = changes.files[index] + if (file === undefined) return new Response('Changed file not found in this Session event.', { status: 404 }) + const { absolutePath: path } = await ctx.workspaceFiles.stat({ sessionId: id as SessionId, workspaceRoot }, file.path, request.signal) + return await openVerified(ctx, request, path, 'open') + } catch (error: unknown) { + request.signal.throwIfAborted() + return new Response('Changed file unavailable.', { status: failureStatus(error) }) } } diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts new file mode 100644 index 0000000000..bdc088d0bb --- /dev/null +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -0,0 +1,146 @@ +/** Changed-file and common-folder native opens resolve the viewed Session's current workspace. */ +import { mkdtemp, rm, writeFile, mkdir, realpath, unlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files' +import { Context } from '@deepseek-ai/cordis' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query' +import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { commonChangedFolder, registerPresentOpen } from '../src/present-open.ts' +import { changedFileUrl, CHANGES_OPEN_PATH, isChangedFile, isChangesData } from '../src/changes.ts' + +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + cleanups.length = 0 + vi.restoreAllMocks() +}) + +const changed = (path: string, display = path): WorkspaceChangedFile => ({ path, display, added: 1, deleted: 0 }) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-changes-open-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const cwd = join(root, 'workspace') + await mkdir(join(cwd, 'src', 'lib'), { recursive: true }) + await writeFile(join(cwd, 'src', 'lib', 'a.ts'), 'a') + await writeFile(join(cwd, 'src', 'b.ts'), 'b') + const outside = join(root, 'outside.txt') + await writeFile(outside, 'outside') + const data = { turn: 1, total: 3, snapshot: { before: 'a'.repeat(40), after: 'b'.repeat(40) }, files: [changed('src/lib/a.ts'), changed('src/b.ts'), changed(outside, '~/outside.txt')] } + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + const session: { cwd?: string } = { cwd } + await ctx.plugin(LocalFileSystem, { cwd }) + ctx.provide('sandboxPolicy', { workspaceRoot: cwd } as never) + await ctx.plugin({ + inject: ['fs', 'sandboxPolicy'], + apply: (scope) => { new WorkspaceFiles(scope, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) }, + }) + const readEvent = vi.fn(async (request: SessionEventReadRequest) => { + if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') + if (request.seq !== 9) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') + return { session, target: { type: 'workspace/changes', data } as SessionEvent } + }) + ctx.provide('sessionQuery', { readEvent } as never) + const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) + ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) + const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) + await ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen }) + const handler = connection.createSharedFetchHandler('/api') + const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' })) + return { root, cwd, ctx, data, session, readEvent, open, opener, outside } +} + +describe('changed files native open route', () => { + it('opens a listed file inside or outside the workspace with its verified Host path', async () => { + const { cwd, open, opener, outside } = await fixture() + expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9&index=0`) + expect(changedFileUrl(SessionId('owner'), 9, null)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9`) + const response = await open() + expect(response.status).toBe(204) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(opener).toHaveBeenLastCalledWith({ path: await realpath(join(cwd, 'src', 'lib', 'a.ts')) }, expect.any(AbortSignal)) + expect((await open('?sessionId=owner&seq=9&index=2')).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside)) + }) + + it('opens the deepest folder containing the workspace files, falling back to the workspace', async () => { + const { cwd, open, opener, data } = await fixture() + expect((await open('?sessionId=owner&seq=9')).status).toBe(204) + expect(opener).toHaveBeenLastCalledWith({ path: await realpath(join(cwd, 'src')) }, expect.any(AbortSignal)) + data.files = [changed('../escaped.ts', '../escaped.ts'), changed('/etc/hosts', '/etc/hosts')] + expect((await open('?sessionId=owner&seq=9')).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(cwd)) + expect(commonChangedFolder('/w', [changed('a/b/c.ts'), changed('a/d.ts'), changed('/x/y.ts')])).toBe('/w/a') + expect(commonChangedFolder('/w', [changed('/x/y.ts')])).toBe('/w') + expect(commonChangedFolder('/w', [changed('../up.ts')])).toBe('/w') + }) + + it.each(['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=9&index=-1', '?sessionId=owner&seq=9&index=1.5', '?sessionId=owner&seq=x'])( + 'rejects invalid coordinates before reading: %s', async (query) => { + const { open, readEvent } = await fixture() + expect((await open(query)).status).toBe(400) + expect(readEvent).not.toHaveBeenCalled() + }) + + it('refuses unrelated Sessions, other events, malformed data, unknown indices, and missing files', async () => { + const { open, readEvent, session, opener, cwd, data } = await fixture() + expect((await open('?sessionId=other&seq=9&index=0')).status).toBe(404) + expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404) + expect((await open('?sessionId=owner&seq=9&index=5')).status).toBe(404) + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data } as unknown as SessionEvent }) + expect((await open()).status).toBe(404) + for (const bad of [null, { turn: 0, files: [], total: 0 }, { turn: 1, files: null, total: 0 }, { turn: 1, files: [{ path: 'a' }], total: 1 }, { turn: 1, files: [], total: 1.5 }]) { + readEvent.mockResolvedValueOnce({ session, target: { type: 'workspace/changes', data: bad } as unknown as SessionEvent }) + expect((await open()).status).toBe(404) + } + await unlink(join(cwd, 'src', 'lib', 'a.ts')) + expect((await open()).status).toBe(404) + await rm(join(cwd, 'src'), { recursive: true }) + expect((await open('?sessionId=owner&seq=9')).status).toBe(404) + expect(opener).not.toHaveBeenCalled() + }) + + it('refuses opening without a desktop or a verified Host mapping and reports launcher failures', async () => { + const { ctx, open, opener } = await fixture() + const desktop = vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: null }) + expect((await open()).status).toBe(409) + desktop.mockRestore() + const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect((await open()).status).toBe(422) + expect((await open('?sessionId=owner&seq=9')).status).toBe(422) + mapping.mockRestore() + opener.mockRejectedValueOnce(new Error('/private/host/path')) + const failed = await open() + expect(failed.status).toBe(500) + expect(await failed.text()).not.toContain('/private/host/path') + expect((await open()).status).toBe(204) + }) + + it('uses the deployment workspace root when the viewed Session has no cwd', async () => { + const { session, open, opener, cwd } = await fixture() + delete session.cwd + expect((await open('?sessionId=owner&seq=9')).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, 'src'))) + }) + + it('validates recorded change records', () => { + expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: true })).toBe(true) + expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: false })).toBe(false) + expect(isChangedFile({ path: '', display: 'a', added: 1, deleted: 2 })).toBe(false) + expect(isChangedFile({ path: 'a', display: '', added: 1, deleted: 2 })).toBe(false) + expect(isChangedFile({ path: 'a', display: 'a', added: 1.5, deleted: 2 })).toBe(false) + expect(isChangedFile([])).toBe(false) + expect(isChangesData({ turn: 1, total: 0, files: [] })).toBe(true) + expect(isChangesData({ turn: '1', total: 0, files: [] })).toBe(false) + expect(isChangesData([])).toBe(false) + }) +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx similarity index 73% rename from packages/client/ui-deliverables/tests/produced-files.client.spec.tsx rename to packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 1460c31415..4acc4b9372 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom /** - * ui-deliverables browser half: the derivation contract of - * `producedForClosing` over engine-published Turn data, the row's rendering - * and opener wiring, and the plugin registrations' fiber-teardown removal - * (HMR safety) against the real SlotRegistry. + * ui-deliverables browser half: the derivation contract of produced paths, + * recorded changes, and deliveries over engine-published Turn data, the + * changed-files card's rendering and opener wiring, and the plugin + * registrations' fiber-teardown removal (HMR safety) against the real + * SlotRegistry. */ import { Context } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render, within } from '@testing-library/react' @@ -23,10 +24,9 @@ import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-clie import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' import { PresentedOpenController } from '../src/client/present-open.ts' -import { ProducedFiles } from '../src/client/ProducedFiles.tsx' import { - basename, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, selectProducedFiles, - type DeliverablesTurnData, + basename, changesForClosing, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, + selectProducedFiles, type ChangesTurnData, type DeliverablesTurnData, } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { en, zh } from '../src/client/locales.ts' @@ -42,6 +42,7 @@ function openProps(controller = new PresentedOpenController()) { usePresentedHost: (select: (state: ReturnType) => T): T => select(controller.host.getSnapshot()), openPresented: vi.fn((...args: Parameters) => controller.open(...args)), + openChanged: vi.fn((...args: Parameters) => controller.openChanged(...args)), usePresentedOpen: (select: (state: ReturnType) => T): T => select(controller.state.getSnapshot()), } @@ -424,58 +425,135 @@ describe('produced-file Turn data', () => { }) }) -describe('ProducedFiles row', () => { - const t = makeTranslate(zh) +const changedFile = (display: string, added = 1, deleted = 0, extra: { binary?: true; path?: string } = {}) => + ({ path: extra.path ?? display, display, added, deleted, ...extra.binary === true ? { binary: true as const } : {} }) - it('renders the bounded chips and opens the file it was clicked for', () => { - const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts', 'h.ts'] - const openFile = vi.fn<(path: string) => void>() +const changesEvent = (seq: number, files: ReturnType[], turn = 1, total = files.length) => + at(seq, 'workspace/changes', { turn, files, total, snapshot: { before: 'a'.repeat(40), after: 'b'.repeat(40) } }) - const view = render() - expect(view.getByText('本轮文件改动')).toBeTruthy() - const row = view.container.querySelector('[data-produced-files-row]') - if (!(row instanceof HTMLElement)) throw new Error('produced row missing') - expect(within(row).getAllByRole('button')).toHaveLength(6) - expect(within(row).getByText('+ 2 个文件')).toBeTruthy() - const chip = view.getByRole('button', { name: '打开 deep/a.html' }) - expect(chip.textContent).toBe('a.html') - expect(chip.getAttribute('title')).toBe('deep/a.html') - expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() - fireEvent.click(chip) - // The row hands over the path it was given; where it opens is the - // Sidebar's decision, not this row's. - expect(openFile).toHaveBeenCalledWith('deep/a.html') +describe('recorded changes Turn data', () => { + it('keeps the latest valid summary of the turn and ignores malformed or empty ones for the card', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + changesEvent(2, [changedFile('old.ts')]), + at(3, 'workspace/changes', { turn: 1, files: [{ path: 'x' }], total: 1 }), + at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + changesEvent(5, [changedFile('src/a.ts', 3, 1), changedFile('b.bin', 0, 0, { binary: true })], 1, 7), + at(6, 'turn/start', { turn: 2 }), + changesEvent(7, [], 2), + ]) + const owner = tailOwner(deliverablesOf(value), 4) + expect(changesForClosing(owner)).toEqual({ seq: 5, total: 7, files: [changedFile('src/a.ts', 3, 1), changedFile('b.bin', 0, 0, { binary: true })] }) + expect(selectDeliverables(owner)).toEqual({ changes: changesForClosing(owner), presented: [] }) + expect(changesForClosing(tailOwner(deliverablesOf(value, 2), 8))).toBeNull() + expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 8))).toBeNull() + expect(changesForClosing(tailOwner(undefined, 8))).toBeNull() }) - it('renders a remainder counter after every chip but the last when every file fits', () => { - const view = render( {}} t={t} />) - const row = view.container.querySelector('[data-produced-files-row]') - if (!(row instanceof HTMLElement)) throw new Error('produced row missing') - expect(within(row).getAllByRole('button')).toHaveLength(3) - // One counter per chip that could be the last visible one; the final chip hides nothing. - expect([...row.querySelectorAll('[data-shown]')].map(node => node.getAttribute('data-shown'))).toEqual(['1', '2']) + it('preserves Turn data identity across unrelated appends', () => { + const value = assembler([at(1, 'turn/start', { turn: 1 }), changesEvent(2, [changedFile('a.ts')])]) + const first = deliverablesOf(value) + value.append(call(3, 'later', 'read', { file_path: 'a.ts' })) + value.flush() + expect(deliverablesOf(value)).toBe(first) }) +}) - it('offers no folder action, because a directory has no preview to open', () => { +describe('ChangedFiles card', () => { + const files = [ + changedFile('config/design-token', 42, 11), changedFile('config/feature-flags.json', 143, 32), + changedFile('config/launch-plan.yaml', 654, 9), changedFile('src/index.ts', 393, 274), + changedFile('~/.zshrc', 0, 0, { binary: true, path: '/home/u/.zshrc' }), + ] + const changes: ChangesTurnData = { seq: 5, files, total: 11 } + + function renderCard(controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }) { + const props = openProps(controller) + props.openChanged.mockResolvedValue(undefined) const openFile = vi.fn<(path: string) => void>() - const overflowing = ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md'] - const view = render() - expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull() - // Nothing in the row reaches the local machine any more. + const view = render() + return { props, openFile, view } + } + + it('summarizes the turn, folds after three rows, and opens rows and the folder natively', () => { + const { props, openFile, view } = renderCard() + const card = view.container.querySelector('[data-changed-files]') + if (!(card instanceof HTMLElement)) throw new Error('changed-files card missing') + expect(within(card).getByText('Edited 11 files')).toBeTruthy() + expect(within(card).getByText('+1,232')).toBeTruthy() + expect(within(card).getByText('-326')).toBeTruthy() + expect(within(card).getAllByRole('listitem')).toHaveLength(3) + expect(within(card).getByText('config/design-token')).toBeTruthy() + expect(within(card).getByText('+42')).toBeTruthy() + expect(within(card).queryByText('src/index.ts')).toBeNull() + fireEvent.click(within(card).getByRole('button', { name: 'Open config/feature-flags.json in default app' })) + expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, 1) + fireEvent.click(within(card).getByRole('button', { name: 'Open the folder containing the changed files' })) + expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, null) expect(openFile).not.toHaveBeenCalled() + const expand = within(card).getByRole('button', { name: 'Show all 5 changed files' }) + expect(expand.getAttribute('aria-expanded')).toBe('false') + expect(expand.textContent).toContain('All 5 files') + fireEvent.click(expand) + expect(within(card).getAllByRole('listitem')).toHaveLength(5) + expect(within(card).getByText('binary')).toBeTruthy() + expect(within(card).getByRole('button', { name: 'Open ~/.zshrc in default app' }).getAttribute('title')).toBe('/home/u/.zshrc') + const collapse = within(card).getByRole('button', { name: 'Collapse changed files' }) + expect(collapse.getAttribute('aria-expanded')).toBe('true') + expect(card.lastElementChild).toBe(collapse) + fireEvent.click(collapse) + expect(within(card).getAllByRole('listitem')).toHaveLength(3) }) - it('uses singular English copy when exactly one file is hidden', () => { - const view = render( - {}} - t={makeTranslate(en)} - />, - ) - const row = view.container.querySelector('[data-produced-files-row]') - if (!(row instanceof HTMLElement)) throw new Error('produced row missing') - expect(within(row).getByText('+ 1 file')).toBeTruthy() + it('previews rows in the Sidebar and offers no folder action without a desktop', () => { + const controller = new PresentedOpenController() + const { openFile, props, view } = renderCard(controller, zh) + controller.host.set('error') + view.rerender() + expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull() + controller.host.set({ name: 'server', available: false, fileManager: null }) + view.rerender() + expect(view.getByText('已编辑 11 个文件')).toBeTruthy() + expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull() + fireEvent.click(view.getByRole('button', { name: '在侧边栏打开 config/design-token' })) + expect(openFile).toHaveBeenCalledWith('config/design-token') + expect(props.openChanged).not.toHaveBeenCalled() + expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件') + }) + + it('shows row and folder gesture states in place of the counts', () => { + const controller = new PresentedOpenController() + controller.state.set({ + '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opening', + '/api/changes.open?sessionId=child-session&seq=5&index=1': 'error', + '/api/changes.open?sessionId=child-session&seq=5&index=2': 'nativeUnavailable', + '/api/changes.open?sessionId=child-session&seq=5': 'opened', + }) + const { view } = renderCard(controller) + expect(view.getByText(en['presented.opening'])).toBeTruthy() + expect((view.getByRole('button', { name: 'Open config/design-token in default app' }) as HTMLButtonElement).disabled).toBe(true) + expect(view.getByText(en['presented.error']).closest('[data-error]')).toBeTruthy() + expect(view.getByText(en['presented.nativeUnavailable'])).toBeTruthy() + expect(view.getByText(en['changes.folderOpened'])).toBeTruthy() + view.unmount() + controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'opening', '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opened' }) + const { view: pending } = renderCard(controller) + expect(pending.getByText(en['changes.folderOpening'])).toBeTruthy() + expect((pending.getByRole('button', { name: 'Open the folder containing the changed files' }) as HTMLButtonElement).disabled).toBe(true) + expect(pending.getByText(en['presented.opened'])).toBeTruthy() + pending.unmount() + controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'error' }) + const { view: failed } = renderCard(controller) + expect(failed.getByText(en['changes.folderError']).closest('[data-error]')).toBeTruthy() + }) + + it('renders without a fold for three files or fewer and beside delivery cards', () => { + const short: ChangesTurnData = { seq: 5, files: files.slice(0, 2), total: 2 } + const { view } = renderCard(new PresentedOpenController(), en, { changes: short, presented: [{ path: 'report.pdf', seq: 6, index: 0 }] as never[] }) + expect(view.getByText('Edited 2 files')).toBeTruthy() + expect(view.queryByRole('button', { name: /Show all|Collapse changed/ })).toBeNull() + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(1) + expect(view.container.querySelector('[data-presented-files-row]')?.parentElement?.getAttribute('data-after-changes')).toBe('true') }) }) @@ -572,6 +650,8 @@ describe('plugin registration', () => { expect(face.hooks.presentedHost.getSnapshot()).toBeNull() await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') + await face.openChanged(SessionId('child-session'), 5, null) + expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() @@ -636,7 +716,7 @@ describe('presented files', () => { expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' })) expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) - expect(view.queryByText('Files changed')).toBeNull() + expect(view.container.querySelector('[data-changed-files]')).toBeNull() }) }) @@ -648,22 +728,23 @@ it.each([null, [], 'invalid'])('declines non-object delivery data: %j', (data) = it.each([{}, { turn: '1', callId: 'bad', files: [] }, { turn: 1.5, callId: 'bad', files: [] }, { turn: 0, callId: 'bad', files: [] }, { turn: 1, files: [] }, { turn: 1, callId: '', files: [] }, { turn: 1, callId: 'bad', files: null }, -])('ignores malformed delivery data and keeps the existing produced row: %j', (data) => { +])('ignores malformed delivery data and keeps the recorded changes card: %j', (data) => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), call(2, 'write-a', 'write', { file_path: 'a.txt', content: 'a' }), result(3, 'write-a'), at(4, 'deliverables/presented', data), + at(5, 'workspace/changes', { turn: 1, files: [{ path: 'a.txt', display: 'a.txt', added: 1, deleted: 0 }], total: 1 }), ]) - const owner = tailOwner(deliverablesOf(value), 5) + const owner = tailOwner(deliverablesOf(value), 6) const matched = selectDeliverables(owner)! const view = render() - expect(view.getByText('Files changed')).toBeTruthy() + expect(view.getByText('Edited 1 files')).toBeTruthy() expect(view.queryByText('Deliverables')).toBeNull() }) it('shows descriptions and falls back to file metadata without hiding extensionless deliveries', () => { - const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) @@ -673,18 +754,9 @@ it('shows descriptions and falls back to file metadata without hiding extensionl expect(view.getByText('report.txt')).toBeTruthy() }) -it('marks delivery cards that directly follow the produced-files row', () => { - const shared = { ...openProps(), openFile: () => {}, sessionId: SessionId('session'), t: makeTranslate(en) } - const presented = [{ path: 'report.txt', seq: 2, index: 0 }] - const view = render() - expect(view.getByText('Files changed')).toBeTruthy() - expect(view.container.querySelector('[data-presented-files-row]')?.parentElement - ?.getAttribute('data-after-produced-files')).toBe('true') -}) - it('distinguishes PDF, Word, Markdown, and code files with compact decorative card icons', () => { const paths = ['report.pdf', 'report.docx', 'README.md', 'index.tsx'] - const view = render( ({ path, seq: 2, index })), }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) const icons = [...view.container.querySelectorAll('[data-presented-file]')].map((card) => { @@ -697,7 +769,7 @@ it('distinguishes PDF, Word, Markdown, and code files with compact decorative ca }) it('lets one delivered file span the complete row without an expansion control', () => { - const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.container.querySelector('[data-presented-files-row]')?.getAttribute('data-single')).toBe('true') @@ -709,7 +781,7 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits const controller = new PresentedOpenController() controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase }) const props = openProps(controller) - const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByText(en[`presented.${phase}`])).toBeTruthy() @@ -720,7 +792,7 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits it('explains a missing desktop and retries failed Host metadata', () => { const controller = new PresentedOpenController() const props = openProps(controller) - const matched = { produced: [], presented: [{ path: 'file.txt', seq: 2, index: 0 }] } + const matched = { changes: null, presented: [{ path: 'file.txt', seq: 2, index: 0 }] } controller.host.set('error') const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) props.reloadPresentedHost.mockResolvedValue(undefined) @@ -732,14 +804,15 @@ it('explains a missing desktop and retries failed Host metadata', () => { }) -it('loads desktop information only when delivery cards appear', () => { +it('loads desktop information once the tail renders and not again while it is known', () => { const controller = new PresentedOpenController() const props = openProps(controller) controller.host.set(null) props.reloadPresentedHost.mockResolvedValue(undefined) const shared = { ...props, openFile: () => {}, sessionId: SessionId('session'), t: makeTranslate(en) } - const view = render() - expect(props.reloadPresentedHost).not.toHaveBeenCalled() - view.rerender() + const view = render() + expect(props.reloadPresentedHost).toHaveBeenCalledOnce() + controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) + view.rerender() expect(props.reloadPresentedHost).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index bf990969ab..1d92e2cef9 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -26,6 +26,22 @@ it('coalesces concurrent card and mention gestures, then allows another open', a await controller.dispose() }) +it('opens changed files and their common folder through their own coordinates', async () => { + const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise>().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.openChanged(id, 9, 1) + await controller.openChanged(id, 9, null) + expect(fetcher.mock.calls.map(call => call[0])).toEqual([ + '/api/changes.open?sessionId=fork&seq=9&index=1', '/api/changes.open?sessionId=fork&seq=9', + ]) + expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9']).toBe('opened') + fetcher.mockResolvedValueOnce(new Response(null, { status: 422 })) + await controller.openChanged(id, 9, 1) + expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable') + await controller.dispose() +}) + it.each(['http', 'network'])('publishes retryable %s failures', async (failure) => { const fetcher = vi.fn() if (failure === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 })) diff --git a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts index eaf8fb13f9..8c718b5b53 100644 --- a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts +++ b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts @@ -1,4 +1,4 @@ -/** Source checks for the produced-files and explicit-delivery layout. */ +/** Source checks for the changed-files and explicit-delivery layout. */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -8,10 +8,10 @@ const read = (name: string): string => describe('deliverables layout', () => { it('keeps the first file-section offset and removes the second', () => { - expect(read('ProducedFiles.module.css')).toMatch(/\.root\s*\{[^}]*margin-top:\s*4px/s) + expect(read('ChangedFiles.module.css')).toMatch(/\.card\s*\{[^}]*margin-top:\s*4px/s) const deliveries = read('Deliverables.module.css') expect(deliveries).toMatch(/\.root\s*\{[^}]*margin-top:\s*4px/s) - expect(deliveries).toMatch(/\.root\[data-after-produced-files='true'\]\s*\{\s*margin-top:\s*0;\s*\}/) + expect(deliveries).toMatch(/\.root\[data-after-changes='true'\]\s*\{\s*margin-top:\s*0;\s*\}/) expect(deliveries).toMatch(/\.file\s*\{[^}]*height:\s*60px;[^}]*padding:\s*8px 10px/s) expect(deliveries).toMatch(/\.fileIcon\s*\{[^}]*box-sizing:\s*border-box;[^}]*width:\s*40px;[^}]*height:\s*40px/s) expect(deliveries).toMatch(/\.fileIcon\s*\{[^}]*border:\s*0\.5px solid var\(--dsw-alias-border-l1\)/s) diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json index 8206c090cb..6949891efb 100644 --- a/packages/client/ui-deliverables/tsconfig.client.json +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -7,6 +7,7 @@ }, "include": [ "src/client", + "src/changes.ts", "src/presented.ts", "src/css-modules.d.ts" ], @@ -53,6 +54,9 @@ { "path": "../../fs/tool-present" }, + { + "path": "../../fs/workspace-changes" + }, { "path": "../../util/workspace-path" } diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json index 289e7df535..f1fd3518a4 100644 --- a/packages/client/ui-deliverables/tsconfig.host.json +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -7,6 +7,7 @@ }, "files": [ "src/index.ts", + "src/changes.ts", "src/presented.ts", "src/present-open.ts" ], @@ -32,6 +33,9 @@ { "path": "../../fs/tool-present" }, + { + "path": "../../fs/workspace-changes" + }, { "path": "../../api/session-controller/tsconfig.host.json" }, diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index dd6411240b..c2b9637c42 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -76,4 +76,5 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'turn/start', 'user/message', 'web/deepseek-search-llm-request', + 'workspace/changes', ]) diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml index c732897309..5e37c0c4bc 100644 --- a/packages/fs/README.i18n.yaml +++ b/packages/fs/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/fs/README.md -README.md: dd3e6f1fab3a8439dd8e2ff15f854e2872549eef -README.zh.md: e4139d05c5bbd08c14bce80c0d2f363e92eeaaf4 +README.md: e0b6e1af4c38acf0896aabbf37af5a3803fb34ae +README.zh.md: b9df359943f225e4b9e95ca4a95c013eeb078bf1 diff --git a/packages/fs/README.md b/packages/fs/README.md index dd3e6f1fab..e0b6e1af4c 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -35,6 +35,7 @@ Eight packages plus the remote sibling `fs-e2b` play the filesystem roles; the s | [`tool-fs-search/`](tool-fs-search/README.md) | Model-facing `glob` and `grep` discovery tools backed by the packaged ripgrep binary | registers on `ctx.tools` | | [`tool-str-replace-editor/`](tool-str-replace-editor/README.md) | Standalone `str_replace_editor` tool: `view`, `create`, `str_replace`, and `insert` over `ctx.fs` | registers on `ctx.tools` | | [`tool-present/`](tool-present/README.md) | Explicit immutable snapshots of delivered files | registers on `ctx.tools` | +| [`workspace-changes/`](workspace-changes/README.md) | Per-turn changed-file summaries recorded from git working-tree snapshots | listens to `session/event`, appends `workspace/changes` | The policy is a plugin, not a service the tools inject: removing it leaves the bare provider's unconditional mutation behavior instead of breaking the tools. The mode fence in `fs-sandbox` and the read-before-edit gate compose. `tool-fs-search` deliberately does not extend the provider contract — search is a process-backed ripgrep workflow, so filesystem backends stay free of a universal search API. diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md index e4139d05c5..b9df359943 100644 --- a/packages/fs/README.zh.md +++ b/packages/fs/README.zh.md @@ -35,6 +35,7 @@ kind: "package-group" | [`tool-fs-search/`](tool-fs-search/README.zh.md) | 由打包 ripgrep 二进制支持的面向模型 `glob` 与 `grep` 发现工具 | 注册到 `ctx.tools` | | [`tool-str-replace-editor/`](tool-str-replace-editor/README.zh.md) | 独立的 `str_replace_editor` 工具:基于 `ctx.fs` 的 `view`、`create`、`str_replace` 与 `insert` | 注册到 `ctx.tools` | | [`tool-present/`](tool-present/README.zh.md) | 显式保存交付文件的不可变快照 | 注册到 `ctx.tools` | +| [`workspace-changes/`](workspace-changes/README.zh.md) | 由 git 工作树快照记录的每轮改动文件摘要 | 监听 `session/event`,追加 `workspace/changes` | 策略是插件,不是工具注入的服务:移除它会留下裸提供方的无条件变更行为,而不会破坏工具。`fs-sandbox` 的模式围栏与编辑前读取门禁可以组合。`tool-fs-search` 有意不扩展提供方约定——搜索是由进程支持的 ripgrep 工作流,因此文件系统后端无需承担通用搜索 API。 diff --git a/packages/fs/workspace-changes/README.i18n.yaml b/packages/fs/workspace-changes/README.i18n.yaml new file mode 100644 index 0000000000..cd0372680b --- /dev/null +++ b/packages/fs/workspace-changes/README.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 packages/fs/workspace-changes/README.md +README.md: c5b83477ad986e8c4cecf403285ad8e1a7106fb7 +README.zh.md: 75d96e9a769412946eb788874564cc2f7b85bdf7 diff --git a/packages/fs/workspace-changes/README.md b/packages/fs/workspace-changes/README.md new file mode 100644 index 0000000000..c5b83477ad --- /dev/null +++ b/packages/fs/workspace-changes/README.md @@ -0,0 +1,101 @@ +--- +description: "Record each top-level turn's changed files from git working-tree snapshots as a durable workspace/changes Session event; configuration, repository tiers, and coverage rules." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-workspace-changes + +English | [中文](README.zh.md) + +## Summary + +This plugin records which files each top-level turn changed, with per-file added and deleted line counts, as a `workspace/changes` Session event. It snapshots the working tree with git when a turn starts and when it ends, diffs the two snapshots, and adds the file-tool edits that git does not cover. The Web changed-files card renders the event; the model never sees it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The shipped Web bundle mounts this plugin. Mount it in any composition with the `subprocess` capability and a git executable on the Host: + +```yaml +- name: '@deepseek-ai/dsh-workspace-changes' + config: + maxFiles: 500 +``` + +| Field | Default | Meaning | +|---|---|---| +| `dshHome` | `$DSH_HOME`, then `~/.dsh` | Harness home whose `workspace-changes/` directory holds shadow repositories | +| `timeoutMs` | `30000` | Milliseconds one git command may run before the turn's record is abandoned | +| `outputMaxBytes` | `8388608` | Bytes of git output retained per command; a larger diff listing abandons the record | +| `maxFiles` | `500` | Maximum files carried by one event; `total` still reports the complete count | +| `shadowExcludes` | `.git/`, `.dsh/`, `node_modules/`, build and cache directories | `info/exclude` patterns for shadow repositories | + +Every Session with a working directory and no subagent origin is recorded; subagent Sessions are not. A working directory inside a git repository snapshots into that repository's object store through a private index, so the repository's own index, work tree, and refs stay untouched and the user's earlier uncommitted changes never enter a summary. A working directory outside any repository snapshots into a shadow repository under the Harness home whose work tree is the working directory itself; the `shadowExcludes` patterns replace the missing `.gitignore`, and a Harness home inside the working directory is excluded automatically. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once. + +Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results: files matching an ignore pattern and files outside the repository or shadow work tree. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the working directory. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded. + +Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The event also carries the two snapshot tree ids. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository or initializes the shadow one, then `add --all` into a temporary index seeded from the persisted index and `write-tree` produce the tree id. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. A shadow repository keeps its refreshed index for the next snapshot; a real repository's index is only read. + +Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work. + +**Runtime invariant:** No companion is published. Event listeners are effect-owned, the Session log owns the recorded summaries, and the git object store owns snapshot trees; no independent observation can diverge from them. + +
+ +----- + + +## Further Exploration + +- [Web deliverables](../../client/ui-deliverables/README.md) — the changed-files card that renders this event and opens its files. +- [Subprocess capability](../../subprocess/README.md) — the seam git runs through. +- [Turn changed-files card decision](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — snapshot design, coverage rules, and rejected alternatives. + + +## Model Experience + +None, as the recorder appends a log-only `workspace/changes` event that only clients read and registers nothing model-facing. + +#### KV Cache effect + +Nothing here enters a model request, so provider cache reuse is unaffected. + +## Known Limitations and Deferred Work + + + +- Snapshot trees are unreferenced objects, so a repository's garbage collection removes them after its prune expiry; the recorded counts survive, the trees needed for a later full-file comparison do not. +- Edits the user makes during a turn are attributed to that turn. +- Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only. +- Windows paths keep native separators in `path`; `display` is always slash-separated. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/fs/workspace-changes/README.zh.md b/packages/fs/workspace-changes/README.zh.md new file mode 100644 index 0000000000..75d96e9a76 --- /dev/null +++ b/packages/fs/workspace-changes/README.zh.md @@ -0,0 +1,101 @@ +--- +description: "用 git 工作树快照把每个顶层轮次改动的文件记录为持久的 workspace/changes Session 事件;配置、仓库分档与覆盖规则。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-workspace-changes + +[English](README.md) | 中文 + +## 概述 + +本插件把每个顶层轮次改动了哪些文件、每个文件的增删行数记录为 `workspace/changes` Session 事件。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。Web 的改动文件卡片渲染该事件;模型看不到它。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +正式提供的 Web bundle 挂载本插件。任何具备 `subprocess` 能力且 Host 上有 git 可执行文件的组合都可以挂载它: + +```yaml +- name: '@deepseek-ai/dsh-workspace-changes' + config: + maxFiles: 500 +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `dshHome` | `$DSH_HOME`,其次 `~/.dsh` | Harness home,其 `workspace-changes/` 目录存放影子仓库 | +| `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 | +| `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 | +| `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 | +| `shadowExcludes` | `.git/`、`.dsh/`、`node_modules/`、构建与缓存目录 | 影子仓库的 `info/exclude` 模式 | + +每个有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。工作目录位于 git 仓库内时,快照通过私有 index 写入该仓库的对象库,仓库自己的 index、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。工作目录不在任何仓库内时,快照写入 Harness home 下的影子仓库,其工作树就是该工作目录;`shadowExcludes` 模式代替缺失的 `.gitignore`,位于工作目录内的 Harness home 会被自动排除。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 + +文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库或影子工作树之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。 + +每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。事件还携带两次快照的 tree id。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库或初始化影子仓库,然后以持久 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。影子仓库保留刷新后的 index 供下次快照使用;真实仓库的 index 只读取。 + +git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作。 + +**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,Session 日志拥有记录的摘要,git 对象库拥有快照树;没有独立观察会与它们分歧。 + +
+ +----- + + +## 进一步探索 + +- [Web 产出物](../../client/ui-deliverables/README.zh.md)——渲染该事件并打开其文件的改动文件卡片。 +- [子进程能力](../../subprocess/README.zh.md)——git 运行所经过的接缝。 +- [本轮改动文件卡片决策](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计、覆盖规则与被否决的备选方案。 + + +## 模型体验 + +无,因为记录器只追加一条仅写日志、只有客户端读取的 `workspace/changes` 事件,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +这里的内容不会进入模型请求,因此不影响提供方缓存复用。 + +## 已知限制与延期工作 + + + +- 快照树是无引用对象,仓库的垃圾回收会在其过期期限后删除它们;记录的计数保留,而将来做整文件对比所需的树不会保留。 +- 用户在轮次进行中自己做的编辑会被算到该轮。 +- 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。 +- Windows 路径在 `path` 中保留原生分隔符;`display` 始终用斜杠分隔。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/fs/workspace-changes/package.json b/packages/fs/workspace-changes/package.json new file mode 100644 index 0000000000..dbcb8db079 --- /dev/null +++ b/packages/fs/workspace-changes/package.json @@ -0,0 +1,58 @@ +{ + "name": "@deepseek-ai/dsh-workspace-changes", + "description": "Per-turn workspace file changes recorded from git working-tree snapshots for the DeepSeek Harness", + "version": "0.1.5-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/fs/workspace-changes" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "MIT", + "files": [ + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "diff": "^9.0.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^" + } +} diff --git a/packages/fs/workspace-changes/src/git.ts b/packages/fs/workspace-changes/src/git.ts new file mode 100644 index 0000000000..c072907336 --- /dev/null +++ b/packages/fs/workspace-changes/src/git.ts @@ -0,0 +1,213 @@ +/** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */ +import { createHash } from 'node:crypto' +import { copyFile, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { parseNumstat, type NumstatEntry } from './numstat.ts' +import { isInside, toPosix } from './paths.ts' + +/** Milliseconds a git child gets to exit after termination starts; a fixed lifecycle constant. */ +const TERMINATE_GRACE_MS = 2_000 +/** Retained stderr tail for diagnostics. */ +const STDERR_TAIL_BYTES = 16 * 1024 + +/** Settled git command facts; a nonzero exit is a result, not an exception. */ +export interface GitRunResult { + exitCode: number | null + stdout: string + stderr: string + /** True when stdout exceeded the output cap and lost its head. */ + truncated: boolean +} + +/** Per-command spawn facts. */ +export interface GitRunOptions { + cwd: string + env?: Readonly> | undefined + stdin?: string | undefined + signal: AbortSignal +} + +/** Bounds every git command runs under. */ +export interface GitLimits { + /** Milliseconds before a command is terminated. */ + timeoutMs: number + /** In-memory stdout cap in bytes. */ + outputMaxBytes: number +} + +/** Runs one resolved git executable with scrubbed environment, timeout, and bounded output. */ +export class GitRunner { + constructor( + private readonly subprocess: SubprocessRuntime, + private readonly executable: string, + private readonly limits: GitLimits, + ) {} + + /** + * Run `git ` to completion. + * @param args - git arguments; never shell-interpreted. + * @param options - working directory, extra environment, stdin data, and cancellation. + * @returns exit facts and collected output. + * @throws when the command times out, is aborted, or cannot spawn. + */ + async run(args: readonly string[], options: GitRunOptions): Promise { + const timeout = AbortSignal.timeout(this.limits.timeoutMs) + const signal = AbortSignal.any([options.signal, timeout]) + const handle = this.subprocess.spawn({ + argv: [this.executable, ...args], + cwd: options.cwd, + stdio: { + stdin: options.stdin === undefined ? 'ignore' : { data: options.stdin }, + stdout: { maxBytes: this.limits.outputMaxBytes }, + stderr: { maxBytes: STDERR_TAIL_BYTES }, + }, + graceMs: TERMINATE_GRACE_MS, + signal, + env: { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env }, + }) + const outcome = await handle.done + if (signal.aborted) { + throw new Error(`git ${args.join(' ')} ${timeout.aborted ? `timed out after ${this.limits.timeoutMs}ms` : 'was aborted'}`) + } + /* v8 ignore start -- collect-mode stdio always yields both readers. */ + const stdout = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false } + const stderr = handle.collected.stderr?.readFrom(0).text ?? '' + /* v8 ignore stop */ + return { exitCode: outcome.exitCode, stdout: stdout.text, stderr, truncated: stdout.lossy } + } +} + +/** + * Reject a failed command with its stderr. + * @param result - settled command facts. + * @param what - command description for the error message. + * @returns the same result when it exited zero. + */ +function ok(result: GitRunResult, what: string): GitRunResult { + if (result.exitCode !== 0) throw new Error(`${what} failed: ${result.stderr.trim()}`) + return result +} + +/** + * The git repository a Session working directory snapshots into: the + * enclosing repository when one exists, otherwise a shadow repository whose + * git directory lives under the Harness home while its work tree is the + * working directory itself. + */ +export interface GitWorkspace { + kind: 'repository' | 'shadow' + /** Work-tree root: the repository top level, or the working directory for a shadow. */ + root: string + /** Absolute git directory holding the index and objects. */ + gitDir: string + /** Environment that addresses the repository for every command. */ + env: Readonly> +} + +/** Where and how shadow repositories are created. */ +export interface ShadowRepositoryOptions { + /** Directory that holds one shadow git directory per working directory. */ + home: string + /** `info/exclude` patterns written on every snapshot. */ + excludes: readonly string[] +} + +/** + * Locate the repository for a working directory, creating the shadow + * repository when the directory is not inside one. + * @param git - command runner. + * @param cwd - absolute Session working directory. + * @param shadow - shadow repository placement and excludes. + * @param signal - cancellation. + * @returns the addressed repository. + */ +export async function locateGitWorkspace( + git: GitRunner, cwd: string, shadow: ShadowRepositoryOptions, signal: AbortSignal, +): Promise { + const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, signal }) + if (found.exitCode === 0) { + const [root, gitDir] = found.stdout.split('\n') as [string, string] + return { kind: 'repository', root, gitDir, env: {} } + } + const gitDir = join(shadow.home, createHash('sha256').update(cwd).digest('hex').slice(0, 16)) + const env = { GIT_DIR: gitDir, GIT_WORK_TREE: cwd } + await mkdir(gitDir, { recursive: true }) + ok(await git.run(['init', '-q'], { cwd, env, signal }), `git init of shadow repository ${gitDir}`) + const excludes = [...shadow.excludes] + if (isInside(cwd, shadow.home)) excludes.push(`/${toPosix(relative(cwd, shadow.home))}/`) + await mkdir(join(gitDir, 'info'), { recursive: true }) + await writeFile(join(gitDir, 'info', 'exclude'), `${excludes.join('\n')}\n`) + return { kind: 'shadow', root: cwd, gitDir, env } +} + +/** + * Write the complete work tree, including untracked and modified files but + * not ignored ones, as a tree object through a private index. The + * repository's own index seeds the stat cache and is never modified, and an + * in-progress merge keeps its unmerged entries; a shadow repository keeps the + * refreshed index for its next snapshot. + * @param git - command runner. + * @param workspace - addressed repository. + * @param signal - cancellation. + * @returns the tree object id. + */ +export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, signal: AbortSignal): Promise { + const scratch = await mkdtemp(join(tmpdir(), 'dsh-workspace-changes-')) + try { + const index = join(scratch, 'index') + const persisted = join(workspace.gitDir, 'index') + // A missing persisted index (a fresh shadow repository) starts from scratch. + await copyFile(persisted, index).then(() => undefined, () => undefined) + const env = { ...workspace.env, GIT_INDEX_FILE: index } + ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`) + const tree = ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim() + if (workspace.kind === 'shadow') await rename(index, persisted) + return tree + } finally { + await rm(scratch, { recursive: true, force: true }) + } +} + +/** + * Per-file line counts between two snapshot trees, with renames detected. + * @param git - command runner. + * @param workspace - addressed repository. + * @param before - turn-start tree id. + * @param after - turn-end tree id. + * @param signal - cancellation. + * @returns changed files relative to the work-tree root. + * @throws when git fails or the output exceeded the cap. + */ +export async function diffTrees( + git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal, +): Promise { + if (before === after) return [] + const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { + cwd: workspace.root, env: workspace.env, signal, + }), 'git diff-tree') + if (result.truncated) throw new Error('git diff-tree output exceeded the configured cap') + return parseNumstat(result.stdout) +} + +/** + * The subset of work-tree paths that the repository ignores. Tracked files + * are never reported, so a tracked file matching an ignore pattern still + * counts as covered by snapshots. + * @param git - command runner. + * @param workspace - addressed repository. + * @param paths - slash-separated paths relative to the work-tree root. + * @param signal - cancellation. + * @returns the ignored members of `paths`. + */ +export async function ignoredPaths( + git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal, +): Promise> { + if (paths.length === 0) return new Set() + const result = await git.run(['check-ignore', '-z', '--stdin'], { + cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal, + }) + if (result.exitCode === 1) return new Set() + return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== '')) +} diff --git a/packages/fs/workspace-changes/src/index.ts b/packages/fs/workspace-changes/src/index.ts new file mode 100644 index 0000000000..a0ae09a993 --- /dev/null +++ b/packages/fs/workspace-changes/src/index.ts @@ -0,0 +1,149 @@ +/** + * Records the files each top-level turn changed as a durable `workspace/changes` + * Session event, derived from git working-tree snapshots taken at turn start + * and turn end plus the hunks file tools persist for paths git does not cover. + */ +import { realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import type {} from '@deepseek-ai/dsh-agent' +import type { Session } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-subprocess' +import type {} from '@deepseek-ai/dsh-tools' +import { GitRunner } from './git.ts' +import { temporaryRoots } from './paths.ts' +import { TurnRecorder } from './recorder.ts' + +export type { WorkspaceChangedFile, WorkspaceChangesData } from './types.ts' + +/** Stable Loader identity. */ +export const name = 'workspace-changes' + +/** Services used to run git and observe turns. */ +export const inject = ['subprocess'] + +/** Directories excluded from shadow repositories, where no `.gitignore` exists. */ +export const DEFAULT_SHADOW_EXCLUDES: readonly string[] = [ + '.git/', '.dsh/', '.DS_Store', 'node_modules/', '.venv/', 'venv/', '__pycache__/', '.pytest_cache/', '.mypy_cache/', + 'dist/', 'build/', 'out/', 'target/', '.cache/', '.next/', '.nuxt/', '.turbo/', 'coverage/', +] + +/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */ +export interface Config { + /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Milliseconds one git command may run before the turn's record is abandoned. */ + timeoutMs: number + /** Bytes of git output retained per command; a larger diff listing abandons the record. */ + outputMaxBytes: number + /** Maximum files carried by one event; `total` still reports the complete count. */ + maxFiles: number + /** `info/exclude` patterns for shadow repositories. */ + shadowExcludes: string[] +} + +/** Schemastery validation for {@link Config}. */ +export const Config: z = z.object({ + dshHome: z.string(), + timeoutMs: z.number().default(30_000), + outputMaxBytes: z.number().default(8 * 1024 * 1024), + maxFiles: z.number().default(500), + shadowExcludes: z.array(z.string()).default([...DEFAULT_SHADOW_EXCLUDES]), +}) + +function eligible(session: Session): string | undefined { + const { cwd, origin, delegationDepth } = session.header + return origin === 'subagent' || (delegationDepth ?? 0) > 0 ? undefined : cwd +} + +/** + * Resolve the git executable once. On macOS the Xcode stub at `/usr/bin/git` + * opens an installer dialog instead of running, so it counts as absent until + * developer tools are selected. + * @param ctx - subprocess capability. + * @param signal - plugin lifetime. + * @returns the executable path, or null when git is unavailable. + */ +async function resolveGit(ctx: Context, signal: AbortSignal): Promise { + let executable: string + try { + executable = await ctx.subprocess.resolveExecutable('git', undefined, signal) + } catch { + return null + } + if (process.platform !== 'darwin' || executable !== '/usr/bin/git') return executable + const probe = ctx.subprocess.spawn({ + argv: ['/usr/bin/xcode-select', '-p'], cwd: homedir(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 1_000, signal, + }) + const outcome = await probe.done.catch(() => ({ exitCode: null })) + return outcome.exitCode === 0 ? executable : null +} + +/** + * Observe top-level turns of every Session with a working directory and append + * their change summaries. + * @param ctx - host context with `subprocess`. + * @param config - validated bounds and placement. + */ +export function apply(ctx: Context, config: Config): void { + for (const [field, value] of [['timeoutMs', config.timeoutMs], ['outputMaxBytes', config.outputMaxBytes], ['maxFiles', config.maxFiles]] as const) { + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`workspace-changes requires a positive integer ${field}`) + } + const lifetime = new AbortController() + const recorders = new Map() + ctx.effect(() => () => { + lifetime.abort() + for (const recorder of recorders.values()) recorder.dispose() + recorders.clear() + }) + const shadow = { home: join(resolveDshHome(config.dshHome), 'workspace-changes'), excludes: config.shadowExcludes } + const roots = temporaryRoots() + const home = realpathSync.native(homedir()) + let runner: Promise | undefined + const gitRunner = (): Promise => { + runner ??= resolveGit(ctx, lifetime.signal).then((executable) => { + if (executable === null) { + ctx.logger.info('workspace-changes: git is unavailable; turn file changes are not recorded') + return null + } + return new GitRunner(ctx.subprocess, executable, { timeoutMs: config.timeoutMs, outputMaxBytes: config.outputMaxBytes }) + }) + return runner + } + const recorderFor = (session: Session, cwd: string): TurnRecorder => { + let recorder = recorders.get(session) + if (recorder === undefined) { + recorder = new TurnRecorder(session, cwd, { + git: gitRunner(), shadow, home, temporaryRoots: roots, maxFiles: config.maxFiles, + warn: (message) => { ctx.logger.warn(message) }, + }) + recorders.set(session, recorder) + } + return recorder + } + ctx.on('session/event', (session, event) => { + if (event.type === 'turn/start') { + const cwd = eligible(session) + if (cwd !== undefined) recorderFor(session, cwd).start(event.data.turn) + return + } + if (event.type === 'tool/result') recorders.get(session)?.observe(event) + else if (event.type === 'turn/end') recorders.get(session)?.end(event.data.turn) + }) + ctx.on('session/disposed', (session) => { + recorders.get(session)?.dispose() + recorders.delete(session) + }) + ctx.on('agent/turn-stopping', async ({ agent, turn }) => { + await recorders.get(agent.session)?.stopping(turn) + }) + ctx.on('tools/pre-execute', async (exec, next) => { + const session = exec.agent?.session + if (session !== undefined) await recorders.get(session)?.settled() + return next() + }) +} diff --git a/packages/fs/workspace-changes/src/numstat.ts b/packages/fs/workspace-changes/src/numstat.ts new file mode 100644 index 0000000000..da54a8ccd9 --- /dev/null +++ b/packages/fs/workspace-changes/src/numstat.ts @@ -0,0 +1,81 @@ +/** Line-count derivation from `git diff-tree --numstat -z` output and from recorded file-tool hunks. */ +import { structuredPatch } from 'diff' +import type { FileDiff } from '@deepseek-ai/dsh-tools' + +/** One `--numstat` record; `path` is slash-separated and relative to the repository root. */ +export interface NumstatEntry { + path: string + added: number + deleted: number + binary: boolean +} + +/** + * Parse NUL-terminated numstat records. A rename record carries an empty path + * followed by the old and new paths; only the new path is kept. + * @param output - complete stdout of `git diff-tree -r -M -z --numstat`. + * @returns records in git's output order. + * @throws when a record is malformed, which indicates truncated output. + */ +export function parseNumstat(output: string): NumstatEntry[] { + const queue = output.split('\0') + if (queue.at(-1) !== '') throw new Error('numstat output is not NUL-terminated') + queue.pop() + const entries: NumstatEntry[] = [] + while (queue.length > 0) { + const record = queue.shift() as string + const [added, deleted, path] = record.split('\t') + if (added === undefined || deleted === undefined || path === undefined) throw new Error(`malformed numstat record: ${record}`) + let target = path + if (target === '') { + queue.shift() + const renamed = queue.shift() + if (renamed === undefined) throw new Error('malformed numstat rename record') + target = renamed + } + const binary = added === '-' + entries.push({ path: target, added: binary ? 0 : Number(added), deleted: binary ? 0 : Number(deleted), binary }) + } + return entries +} + +/** + * Sum the added and deleted lines over recorded hunks. Context lines appear on + * both sides of a hunk and cancel out; a hunk without prior text counts every + * line as added. + * @param diffs - the applied hunks a file tool persisted with its result. + * @returns line totals for one file. + */ +export function hunkLineCounts(diffs: readonly FileDiff[]): { added: number; deleted: number } { + let added = 0 + let deleted = 0 + for (const diff of diffs) { + const patch = structuredPatch('', '', diff.oldText ?? '', diff.newText) + for (const hunk of patch.hunks) { + for (const line of hunk.lines) { + if (line.startsWith('+')) added += 1 + else if (line.startsWith('-')) deleted += 1 + } + } + } + return { added, deleted } +} + +/** + * Narrow a tool result's opaque `meta` to the file-tool hunk list. + * @param meta - persisted result metadata. + * @returns the hunks, or undefined when the metadata carries none. + */ +export function fileDiffsOf(meta: unknown): FileDiff[] | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const { diffs } = meta as Record + if (!Array.isArray(diffs) || diffs.length === 0) return undefined + const out: FileDiff[] = [] + for (const value of diffs) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined + const { path, oldText, newText } = value as Record + if (typeof path !== 'string' || (oldText !== null && typeof oldText !== 'string') || typeof newText !== 'string') return undefined + out.push({ path, oldText, newText }) + } + return out +} diff --git a/packages/fs/workspace-changes/src/paths.ts b/packages/fs/workspace-changes/src/paths.ts new file mode 100644 index 0000000000..0e37704846 --- /dev/null +++ b/packages/fs/workspace-changes/src/paths.ts @@ -0,0 +1,78 @@ +/** Path classification and display forms for changed files. */ +import { realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, relative, resolve, sep } from 'node:path' + +/** Slash-separated form of a native relative path. */ +export function toPosix(path: string): string { + return path.split(sep).join('/') +} + +/** Whether `path` is `root` or lies under it. */ +export function isInside(root: string, path: string): boolean { + const rel = relative(root, path) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} + +/** + * Canonical spellings of the temporary directories a workspace-write sandbox + * grants: the host `/tmp` and the platform temp area, each also in its + * symlink-resolved form so `/tmp` and `/private/tmp` match alike. + * @param candidates - directories to canonicalize. + * @returns absolute directory paths. + */ +export function temporaryRoots(candidates: readonly string[] = ['/tmp', tmpdir()]): string[] { + const roots = new Set() + for (const root of candidates) { + roots.add(root) + try { + roots.add(realpathSync.native(root)) + } catch { + // A missing temporary root matches nothing. + } + } + return [...roots] +} + +/** + * Whether a file lives under a temporary root, where the model keeps scratch work. + * @param path - absolute file path. + * @param roots - {@link temporaryRoots}. + * @returns true for scratch paths that never enter the change summary. + */ +export function isTemporaryPath(path: string, roots: readonly string[]): boolean { + return roots.some(root => isInside(root, path)) +} + +/** + * Resolve a file-tool path against the Session working directory. + * @param cwd - absolute Session working directory. + * @param path - model-facing path, relative or absolute. + * @returns the absolute native path. + */ +export function absolutePathOf(cwd: string, path: string): string { + return resolve(cwd, path) +} + +/** Sort key and label rules; see `WorkspaceChangedFile.display`. */ +export function displayPathOf(absolute: string, cwd: string, root: string, home: string): string { + const rel = relative(cwd, absolute) + if (!rel.startsWith('..') && !isAbsolute(rel)) return toPosix(rel) + if (isInside(root, absolute)) return toPosix(rel) + if (home !== '' && isInside(home, absolute)) return `~/${toPosix(relative(home, absolute))}` + return toPosix(absolute) +} + +/** The durable `path` field: relative inside the working directory, absolute elsewhere. */ +export function durablePathOf(absolute: string, cwd: string): string { + const rel = relative(cwd, absolute) + return !rel.startsWith('..') && !isAbsolute(rel) ? toPosix(rel) : absolute +} + +/** + * Code-unit order of display paths, which places `../` and absolute paths + * before letters and matches git's own listing order for relative paths. + */ +export function compareDisplay(a: { display: string }, b: { display: string }): number { + return a.display < b.display ? -1 : a.display > b.display ? 1 : 0 +} diff --git a/packages/fs/workspace-changes/src/recorder.ts b/packages/fs/workspace-changes/src/recorder.ts new file mode 100644 index 0000000000..1cd90daab7 --- /dev/null +++ b/packages/fs/workspace-changes/src/recorder.ts @@ -0,0 +1,188 @@ +/** Per-Session turn recorder: snapshot at turn start, diff and append at turn end. */ +import { realpath } from 'node:fs/promises' +import { relative } from 'node:path' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { FileDiff } from '@deepseek-ai/dsh-tools' +import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace, type ShadowRepositoryOptions } from './git.ts' +import { fileDiffsOf, hunkLineCounts } from './numstat.ts' +import { absolutePathOf, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, toPosix } from './paths.ts' +import type { WorkspaceChangedFile } from './types.ts' + +/** Facts shared by every recorder of one plugin instance. */ +export interface RecorderEnvironment { + /** Resolves to the runner, or null when git is unavailable and no turn records anything. */ + git: Promise + shadow: ShadowRepositoryOptions + /** Canonical absolute home directory abbreviated as `~` in display paths. */ + home: string + /** Temporary roots whose files never enter a summary. */ + temporaryRoots: readonly string[] + /** Maximum files carried by one event. */ + maxFiles: number + /** Failure reporter; a failed turn records nothing and the next turn retries. */ + warn: (message: string) => void +} + +interface Baseline { git: GitRunner; workspace: GitWorkspace; tree: string; cwd: string } + +/** Symlink-resolved path when the target exists, otherwise the lexical path. */ +async function canonicalPath(path: string): Promise { + try { + return await realpath(path) + } catch { + // A deleted or unreadable target keeps its lexical spelling. + return path + } +} + +/** + * Serializes one Session's git work: the turn-start snapshot, the turn-end + * snapshot with its diff, and the appended `workspace/changes` event. Tool + * execution waits for pending work so a snapshot never races a mutation. + */ +export class TurnRecorder { + private chain: Promise = Promise.resolve() + private baseline: Baseline | null = null + private turn = 0 + /** File-tool hunks by their model-facing path; canonicalized when the record is built. */ + private hunks = new Map() + private lastToolResultSeq = -1 + private recordedAfterSeq = -1 + private readonly lifetime = new AbortController() + + constructor( + private readonly session: Session, + private readonly cwd: string, + private readonly env: RecorderEnvironment, + ) {} + + /** Open a turn: reset per-turn state and queue the baseline snapshot. */ + start(turn: number): void { + this.turn = turn + this.baseline = null + this.hunks = new Map() + this.lastToolResultSeq = -1 + this.recordedAfterSeq = -1 + void this.enqueue(async (signal) => { + const git = await this.env.git + if (git === null) return + // git reports symlink-resolved paths; every comparison uses that form. + const cwd = await realpath(this.cwd) + const workspace = await locateGitWorkspace(git, cwd, this.env.shadow, signal) + const tree = await snapshotTree(git, workspace, signal) + this.baseline = { git, workspace, tree, cwd } + }) + } + + /** Remember a settled tool result and any file-tool hunks it carries. */ + observe(event: SessionEvent<'tool/result'>): void { + if (event.data.turn !== this.turn) return + this.lastToolResultSeq = event.seq + if (event.data.message.content[0].isError === true) return + const diffs = fileDiffsOf(event.data.meta) + if (diffs === undefined) return + for (const diff of diffs) { + const list = this.hunks.get(diff.path) + if (list === undefined) this.hunks.set(diff.path, [diff]) + else list.push(diff) + } + } + + /** + * Record the turn's changes inside the turn, before `turn/end` commits. + * @param turn - the stopping turn. + * @returns after the event is appended or the attempt failed. + */ + stopping(turn: number): Promise { + if (turn !== this.turn) return Promise.resolve() + return this.enqueue(signal => this.record(signal)) + } + + /** Record after `turn/end` when the in-turn record is missing or stale. */ + end(turn: number): void { + if (turn !== this.turn || this.recordedAfterSeq >= this.lastToolResultSeq) return + void this.enqueue(signal => this.record(signal)) + } + + /** Resolves once every queued snapshot and record has settled. */ + settled(): Promise { + return this.chain + } + + /** Abort queued git work; the recorder accepts nothing afterwards. */ + dispose(): void { + this.lifetime.abort() + } + + private enqueue(task: (signal: AbortSignal) => Promise): Promise { + const run = this.chain.then(async () => { + if (this.lifetime.signal.aborted) return + try { + await task(this.lifetime.signal) + } catch (error: unknown) { + this.warnUnlessDisposed(error) + } + }) + this.chain = run + return run + } + + /** A failure after disposal is expected cancellation and stays silent. */ + private warnUnlessDisposed(error: unknown): void { + if (!this.lifetime.signal.aborted) this.env.warn(`workspace-changes: ${String(error)}`) + } + + private async record(signal: AbortSignal): Promise { + if (this.baseline === null || this.lastToolResultSeq < 0) return + const { git, workspace, tree: before, cwd } = this.baseline + const after = await snapshotTree(git, workspace, signal) + const files = new Map() + for (const entry of await diffTrees(git, workspace, before, after, signal)) { + const absolute = absolutePathOf(workspace.root, entry.path) + files.set(absolute, this.changedFile(absolute, cwd, workspace, entry)) + } + const hunks = new Map() + for (const [path, list] of this.hunks) { + const absolute = await canonicalPath(absolutePathOf(cwd, path)) + hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list]) + } + const inside: string[] = [] + const outside: string[] = [] + for (const absolute of hunks.keys()) { + if (files.has(absolute)) continue + // The working directory is the user's workspace even when it lives under a temporary root. + if (!isInside(cwd, absolute) && isTemporaryPath(absolute, this.env.temporaryRoots)) continue + if (isInside(workspace.root, absolute)) inside.push(absolute) + else outside.push(absolute) + } + const workTreePath = (absolute: string): string => toPosix(relative(workspace.root, absolute)) + const ignored = await ignoredPaths(git, workspace, inside.map(workTreePath), signal) + const toolOnly = new Set([...outside, ...inside.filter(absolute => ignored.has(workTreePath(absolute)))]) + for (const [absolute, list] of hunks) { + if (!toolOnly.has(absolute)) continue + files.set(absolute, this.changedFile(absolute, cwd, workspace, { ...hunkLineCounts(list), binary: false })) + } + const sorted = [...files.values()].sort(compareDisplay) + // An empty list after an earlier in-turn record supersedes that record. + if (sorted.length === 0 && this.recordedAfterSeq < 0) return + const event = this.session.append('workspace/changes', { + turn: this.turn, + files: sorted.slice(0, this.env.maxFiles), + total: sorted.length, + snapshot: { before, after }, + }) + this.recordedAfterSeq = event.seq + } + + private changedFile( + absolute: string, cwd: string, workspace: GitWorkspace, counts: { added: number; deleted: number; binary: boolean }, + ): WorkspaceChangedFile { + return { + path: durablePathOf(absolute, cwd), + display: displayPathOf(absolute, cwd, workspace.root, this.env.home), + added: counts.added, + deleted: counts.deleted, + ...counts.binary ? { binary: true as const } : {}, + } + } +} diff --git a/packages/fs/workspace-changes/src/types.ts b/packages/fs/workspace-changes/src/types.ts new file mode 100644 index 0000000000..1092e8258c --- /dev/null +++ b/packages/fs/workspace-changes/src/types.ts @@ -0,0 +1,38 @@ +/** Durable per-turn workspace change summaries and their Session event. */ + +/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */ +export interface WorkspaceChangedFile { + /** Path relative to the Session working directory, or an absolute Host path outside it. */ + path: string + /** + * Sort key and label: the relative path inside the working directory, a + * `../` path for repository files above it, a `~` path under the home + * directory, otherwise the absolute path. Always slash-separated. + */ + display: string + /** Lines added; zero for a binary file. */ + added: number + /** Lines deleted; zero for a binary file. */ + deleted: number + /** Present when git reported the file as binary. */ + binary?: true +} + +/** Files changed during one top-level turn. */ +export interface WorkspaceChangesData { + /** The turn whose file changes this summary describes. */ + turn: number + /** Changed files in `display` order, capped at the plugin's `maxFiles`. */ + files: WorkspaceChangedFile[] + /** Complete changed-file count, including files omitted by the cap. */ + total: number + /** Git tree object ids of the turn-start and turn-end working-tree snapshots. */ + snapshot: { before: string; after: string } +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** Files changed by a completed top-level turn; the latest event for one turn replaces earlier ones. */ + 'workspace/changes': WorkspaceChangesData + } +} diff --git a/packages/fs/workspace-changes/tests/git.spec.ts b/packages/fs/workspace-changes/tests/git.spec.ts new file mode 100644 index 0000000000..4cf8b963a7 --- /dev/null +++ b/packages/fs/workspace-changes/tests/git.spec.ts @@ -0,0 +1,125 @@ +/** Git command bounds, snapshot recovery, and diff failure reporting. */ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import { GitRunner, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree } from '../src/git.ts' +import { TurnRecorder } from '../src/recorder.ts' +import { temporaryRoots } from '../src/paths.ts' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { git, scratchDir } from './support.ts' + +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + cleanups.length = 0 +}) + +async function runner(limits = { timeoutMs: 30_000, outputMaxBytes: 1024 * 1024 }, executable = 'git') { + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(LocalSubprocessRuntime) + return { ctx, git: new GitRunner(ctx.subprocess, await ctx.subprocess.resolveExecutable(executable).catch(() => executable), limits) } +} + +const signal = new AbortController().signal + +describe('GitRunner', () => { + it('reports timeouts and external aborts as failures', async () => { + const cwd = await scratchDir('dsh-git-runner-', cleanups) + const { git: slow } = await runner({ timeoutMs: 1, outputMaxBytes: 1024 }) + await expect(slow.run(['--version'], { cwd, signal })).rejects.toThrow('timed out after 1ms') + const { git: quick } = await runner() + const aborted = new AbortController() + setTimeout(() => { aborted.abort() }, 0) + await expect(quick.run(['--version'], { cwd, signal: aborted.signal })).rejects.toThrow('git --version was aborted') + const ok = await quick.run(['--version'], { cwd, signal }) + expect(ok.exitCode).toBe(0) + expect(ok.stdout).toContain('git version') + }) +}) + +describe('snapshots and diffs', () => { + it('snapshots a repository whose index holds unmerged entries without touching that index', async () => { + const cwd = await scratchDir('dsh-git-conflict-', cleanups) + git(cwd, 'init', '-q', '-b', 'main') + await writeFile(join(cwd, 'f.txt'), 'base\n') + git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'base') + git(cwd, 'checkout', '-q', '-b', 'side') + await writeFile(join(cwd, 'f.txt'), 'side\n') + git(cwd, 'commit', '-q', '-am', 'side') + git(cwd, 'checkout', '-q', 'main') + await writeFile(join(cwd, 'f.txt'), 'main\n') + git(cwd, 'commit', '-q', '-am', 'main') + expect(() => git(cwd, 'merge', 'side')).toThrow() + expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt') + const { git: runnerGit } = await runner() + const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal) + expect(workspace.kind).toBe('repository') + const tree = await snapshotTree(runnerGit, workspace, signal) + expect(tree).toMatch(/^[0-9a-f]{40,64}$/) + expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt') + }) + + it('fails loudly when the addressed repository cannot be written or diffed', async () => { + const cwd = await scratchDir('dsh-git-broken-', cleanups) + const { git: runnerGit } = await runner() + const broken = { kind: 'shadow' as const, root: cwd, gitDir: join(cwd, 'missing'), env: { GIT_DIR: join(cwd, 'missing'), GIT_WORK_TREE: cwd } } + await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in') + await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed') + expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set()) + git(cwd, 'init', '-q') + const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal) + await expect(diffTrees(runnerGit, workspace, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow('git diff-tree failed') + await writeFile(join(cwd, 'many.txt'), Array.from({ length: 50 }, (_, index) => `line ${index}`).join('\n')) + const before = await snapshotTree(runnerGit, workspace, signal) + await writeFile(join(cwd, 'many.txt'), 'gone') + for (let index = 0; index < 20; index += 1) await writeFile(join(cwd, `file-${index}.txt`), 'x\n') + const after = await snapshotTree(runnerGit, workspace, signal) + const { git: tiny } = await runner({ timeoutMs: 30_000, outputMaxBytes: 16 }) + await expect(diffTrees(tiny, workspace, before, after, signal)).rejects.toThrow('exceeded') + expect((await diffTrees(runnerGit, workspace, before, after, signal)).length).toBe(21) + }) + + it('refuses a shadow home whose git directory cannot be initialized', async () => { + const cwd = await scratchDir('dsh-git-shadow-broken-', cleanups) + const home = join(cwd, 'home') + const { git: runnerGit } = await runner() + const probe = await locateGitWorkspace(runnerGit, cwd, { home, excludes: ['x/'] }, signal) + expect(probe.kind).toBe('shadow') + await mkdir(join(probe.gitDir, 'objects', 'broken'), { recursive: true }) + await writeFile(join(probe.gitDir, 'HEAD'), 'not a ref') + await writeFile(join(probe.gitDir, 'config'), '[core]\n\tbare = maybe\n') + await expect(locateGitWorkspace(runnerGit, cwd, { home, excludes: [] }, signal)).rejects.toThrow('git init of shadow repository') + }) +}) + +describe('TurnRecorder', () => { + it('stays silent when disposed while git work is pending, and warns on failures otherwise', async () => { + const cwd = await scratchDir('dsh-recorder-', cleanups) + const { ctx, git: runnerGit } = await runner() + const session = ctx.sessions.create(SessionId('recorder'), { meta: { cwd } }) + const warnings: string[] = [] + let release!: (runner: GitRunner | null) => void + const gate = new Promise((resolve) => { release = resolve }) + const env = { git: gate, shadow: { home: join(cwd, 'home'), excludes: [] }, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } } + const disposed = new TurnRecorder(session, cwd, env) + disposed.start(1) + await new Promise(resolve => setTimeout(resolve, 5)) + disposed.dispose() + release(runnerGit) + await disposed.settled() + disposed.start(2) + await disposed.settled() + expect(warnings).toEqual([]) + + const { git: missing } = await runner(undefined, '/nonexistent/git-binary') + const failing = new TurnRecorder(session, cwd, { ...env, git: Promise.resolve(missing) }) + failing.start(1) + await failing.settled() + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('workspace-changes:') + }) +}) diff --git a/packages/fs/workspace-changes/tests/loader-composition.spec.ts b/packages/fs/workspace-changes/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..1d3f87f9a3 --- /dev/null +++ b/packages/fs/workspace-changes/tests/loader-composition.spec.ts @@ -0,0 +1,82 @@ +/** + * REAL-composition proof: the shipped YAML rows (session store, local + * subprocess runtime, workspace-changes) boot through the vendored Loader and + * a logged turn ends with its recorded changes. + */ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import * as WorkspaceChangesPlugin from '@deepseek-ai/dsh-workspace-changes' +import { changes, endTurn, git, startTurn, toolCall } from './support.ts' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('real Loader composition', () => { + it('loads the shipped rows and records a turn’s changes', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-workspace-changes-loader-')) + const cwd = join(root, 'ws') + await writeFile(join(root, 'cordis.yml'), [ + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-subprocess-local'", + "- name: '@deepseek-ai/dsh-workspace-changes'", + ' config:', + ` dshHome: ${JSON.stringify(join(root, 'home'))}`, + '', + ].join('\n')) + context = new Context() + context.baseUrl = `${pathToFileURL(root).href}/` + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], + ['@deepseek-ai/dsh-workspace-changes', WorkspaceChangesPlugin], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(root, 'cordis.yml')).href } }) + await context.loader.await() + const unloaded = [...context.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + + await writeFile(join(root, 'placeholder'), '') + await rm(cwd, { recursive: true, force: true }) + git(root, 'init', '-q', cwd) + await writeFile(join(cwd, 'tracked.txt'), 'one\n') + git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'init') + const session = context.sessions.create(SessionId('composed'), { meta: { cwd } }) + startTurn(session, 1) + await context.waterfall('tools/pre-execute', { agent: { session } } as never, () => Promise.resolve(undefined as never)) + await writeFile(join(cwd, 'tracked.txt'), 'one\ntwo\n') + toolCall(session, 1, 'bash', { command: 'x' }) + endTurn(session, 1) + await context.waterfall('tools/pre-execute', { agent: { session } } as never, () => Promise.resolve(undefined as never)) + const [recorded, ...rest] = changes(session) + expect(rest).toEqual([]) + expect(recorded).toMatchObject({ turn: 1, total: 1, files: [{ path: 'tracked.txt', display: 'tracked.txt', added: 1, deleted: 0 }] }) + expect(recorded!.snapshot.before).toMatch(/^[0-9a-f]+$/) + expect(recorded!.snapshot.after).toMatch(/^[0-9a-f]+$/) + }) +}) diff --git a/packages/fs/workspace-changes/tests/numstat.spec.ts b/packages/fs/workspace-changes/tests/numstat.spec.ts new file mode 100644 index 0000000000..6bf36b854b --- /dev/null +++ b/packages/fs/workspace-changes/tests/numstat.spec.ts @@ -0,0 +1,46 @@ +/** Numstat parsing and hunk line counting. */ +import { describe, expect, it } from 'vitest' +import { fileDiffsOf, hunkLineCounts, parseNumstat } from '../src/numstat.ts' + +describe('parseNumstat', () => { + it('reads plain, binary, and rename records', () => { + const output = ['3\t1\tsrc/a.ts', '-\t-\timg.png', '2\t0\t', 'old.txt', 'new.txt', ''].join('\u0000') + expect(parseNumstat(output)).toEqual([ + { path: 'src/a.ts', added: 3, deleted: 1, binary: false }, + { path: 'img.png', added: 0, deleted: 0, binary: true }, + { path: 'new.txt', added: 2, deleted: 0, binary: false }, + ]) + }) + + it('returns nothing for empty output', () => { + expect(parseNumstat('')).toEqual([]) + }) + + it('rejects output without a final terminator or with malformed records', () => { + expect(() => parseNumstat('1\t1\ta.txt')).toThrow('NUL-terminated') + expect(() => parseNumstat('garbage\0')).toThrow('malformed numstat record') + expect(() => parseNumstat('1\t0\t\0old.txt\0')).toThrow('rename') + }) +}) + +describe('hunkLineCounts', () => { + it('counts changed lines and ignores context', () => { + expect(hunkLineCounts([ + { path: 'a', oldText: 'keep\nold\nkeep', newText: 'keep\nnew\nnew2\nkeep' }, + { path: 'a', oldText: null, newText: 'x\ny\n' }, + ])).toEqual({ added: 4, deleted: 1 }) + }) +}) + +describe('fileDiffsOf', () => { + it('narrows the file-tool metadata and rejects anything else', () => { + expect(fileDiffsOf({ diffs: [{ path: 'a', oldText: null, newText: 'b' }] })).toEqual([{ path: 'a', oldText: null, newText: 'b' }]) + expect(fileDiffsOf(undefined)).toBeUndefined() + expect(fileDiffsOf([])).toBeUndefined() + expect(fileDiffsOf({ diffs: [] })).toBeUndefined() + expect(fileDiffsOf({ diffs: [null] })).toBeUndefined() + expect(fileDiffsOf({ diffs: [{ path: 1, oldText: null, newText: '' }] })).toBeUndefined() + expect(fileDiffsOf({ diffs: [{ path: 'a', oldText: 2, newText: '' }] })).toBeUndefined() + expect(fileDiffsOf({ diffs: [{ path: 'a', oldText: 'x', newText: 3 }] })).toBeUndefined() + }) +}) diff --git a/packages/fs/workspace-changes/tests/paths.spec.ts b/packages/fs/workspace-changes/tests/paths.spec.ts new file mode 100644 index 0000000000..4ef77f23d0 --- /dev/null +++ b/packages/fs/workspace-changes/tests/paths.spec.ts @@ -0,0 +1,46 @@ +/** Display, durable, and temporary path rules. */ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { compareDisplay, displayPathOf, durablePathOf, isTemporaryPath, temporaryRoots } from '../src/paths.ts' + +const cwd = '/home/u/proj/pkg' +const root = '/home/u/proj' +const home = '/home/u' + +describe('displayPathOf', () => { + it('prefers cwd-relative, then repository-relative, then home, then absolute', () => { + expect(displayPathOf('/home/u/proj/pkg/src/a.ts', cwd, root, home)).toBe('src/a.ts') + expect(displayPathOf('/home/u/proj/other/b.ts', cwd, root, home)).toBe('../other/b.ts') + expect(displayPathOf('/home/u/.zshrc', cwd, root, home)).toBe('~/.zshrc') + expect(displayPathOf('/etc/hosts', cwd, root, home)).toBe('/etc/hosts') + expect(displayPathOf('/home/u/.zshrc', cwd, root, '')).toBe('/home/u/.zshrc') + }) +}) + +describe('durablePathOf', () => { + it('keeps cwd-relative paths relative and everything else absolute', () => { + expect(durablePathOf('/home/u/proj/pkg/a.ts', cwd)).toBe('a.ts') + expect(durablePathOf('/home/u/proj/b.ts', cwd)).toBe('/home/u/proj/b.ts') + }) +}) + +describe('temporary paths', () => { + it('matches the platform temp roots in raw and canonical form and skips missing candidates', () => { + const roots = temporaryRoots() + expect(roots).toContain('/tmp') + expect(isTemporaryPath(join(tmpdir(), 'scratch.txt'), roots)).toBe(true) + expect(isTemporaryPath('/tmp/x', roots)).toBe(true) + expect(isTemporaryPath('/tmpfoo/x', roots)).toBe(false) + expect(isTemporaryPath('/home/u/x', roots)).toBe(false) + expect(temporaryRoots(['/definitely/missing/root'])).toEqual(['/definitely/missing/root']) + }) +}) + +describe('compareDisplay', () => { + it('orders by code units so parent and absolute paths lead', () => { + const sorted = [{ display: 'src/b' }, { display: '~/x' }, { display: '../a' }, { display: '/etc/h' }, { display: 'src/a' }].sort(compareDisplay) + expect(sorted.map(file => file.display)).toEqual(['../a', '/etc/h', 'src/a', 'src/b', '~/x']) + expect(compareDisplay({ display: 'a' }, { display: 'a' })).toBe(0) + }) +}) diff --git a/packages/fs/workspace-changes/tests/plugin.spec.ts b/packages/fs/workspace-changes/tests/plugin.spec.ts new file mode 100644 index 0000000000..acd3065da3 --- /dev/null +++ b/packages/fs/workspace-changes/tests/plugin.spec.ts @@ -0,0 +1,334 @@ +/** The plugin records each top-level turn's file changes from real git snapshots. */ +import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import * as WorkspaceChanges from '../src/index.ts' +import { changes, endTurn, git, scratchDir, settle, startTurn, toolCall } from './support.ts' + +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + cleanups.length = 0 + vi.restoreAllMocks() +}) + +async function boot(config: Partial = {}) { + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(LocalSubprocessRuntime) + const dshHome = config.dshHome ?? await scratchDir('dsh-workspace-changes-home-', cleanups) + const fiber = await ctx.plugin(WorkspaceChanges, { ...config, dshHome } as WorkspaceChanges.Config) + return { ctx, fiber, dshHome } +} + +async function repository(): Promise { + const cwd = await scratchDir('dsh-workspace-changes-repo-', cleanups) + git(cwd, 'init', '-q', '-b', 'main') + await writeFile(join(cwd, 'a.txt'), 'l1\nl2\nl3\n') + await writeFile(join(cwd, 'b.txt'), 'x\n') + await writeFile(join(cwd, 'same.txt'), 'same\n') + await writeFile(join(cwd, '.gitignore'), '.env*\n') + git(cwd, 'add', '-A') + git(cwd, 'commit', '-q', '-m', 'init') + return cwd +} + +describe('workspace-changes in a repository', () => { + it('records the turn’s own changes and excludes the user’s prior uncommitted work', async () => { + const cwd = await repository() + await writeFile(join(cwd, 'b.txt'), 'x user\n') + await writeFile(join(cwd, 'u.txt'), 'user untracked\n') + const { ctx } = await boot() + const session = ctx.sessions.create(SessionId('repo'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + + await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n') + toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } }) + await mkdir(join(cwd, 'sub', 'dir'), { recursive: true }) + await writeFile(join(cwd, 'sub', 'dir', 'c.txt'), 'c\n') + await writeFile(join(cwd, 'new.txt'), 'n1\nn2\n') + await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255)) + toolCall(session, 1, 'bash', { command: 'printf > files' }) + await writeFile(join(cwd, '.env'), 'A=1\nB=2\n') + toolCall(session, 1, 'write', { file_path: '.env' }, { meta: { diffs: [{ path: '.env', oldText: null, newText: 'A=1\n' }] } }) + toolCall(session, 1, 'edit', { file_path: '.env' }, { meta: { diffs: [{ path: '.env', oldText: 'A=1\n', newText: 'A=1\nB=2\n' }] } }) + toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt') }, { + meta: { diffs: [{ path: join(tmpdir(), 'scratch.txt'), oldText: null, newText: 'scratch\n' }] }, + }) + toolCall(session, 1, 'write', { file_path: 'ignored-error' }, { isError: true, meta: { diffs: [{ path: 'failed.txt', oldText: null, newText: 'x' }] } }) + toolCall(session, 1, 'write', { file_path: '.env.gone' }, { meta: { diffs: [{ path: '.env.gone', oldText: null, newText: 'x' }] } }) + toolCall(session, 1, 'write', { file_path: 'same.txt' }, { meta: { diffs: [{ path: 'same.txt', oldText: 'same', newText: 'same' }] } }) + toolCall(session, 2, 'write', { file_path: 'other-turn' }, { meta: { diffs: [{ path: 'other.txt', oldText: null, newText: 'x' }] } }) + endTurn(session, 1) + await settle(ctx, session) + + const [recorded, ...rest] = changes(session) + expect(rest).toEqual([]) + expect(recorded).toMatchObject({ turn: 1, total: 6 }) + expect(recorded!.snapshot.before).toMatch(/^[0-9a-f]{40,64}$/) + expect(recorded!.snapshot.after).toMatch(/^[0-9a-f]{40,64}$/) + expect(recorded!.files).toEqual([ + { path: '.env', display: '.env', added: 2, deleted: 0 }, + { path: '.env.gone', display: '.env.gone', added: 1, deleted: 0 }, + { path: 'a.txt', display: 'a.txt', added: 2, deleted: 1 }, + { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true }, + { path: 'new.txt', display: 'new.txt', added: 2, deleted: 0 }, + { path: 'sub/dir/c.txt', display: 'sub/dir/c.txt', added: 1, deleted: 0 }, + ]) + expect(git(cwd, 'status', '--porcelain').split('\n').filter(Boolean).sort()).toEqual([ + ' M a.txt', ' M b.txt', '?? bin.dat', '?? new.txt', '?? sub/', '?? u.txt', + ]) + }) + + it('places files above the working directory and outside the repository by their display rule', async () => { + const root = await repository() + const cwd = join(root, 'pkg') + await mkdir(cwd) + const outside = await mkdtemp(join(homedir(), '.dsh-workspace-changes-test-')) + cleanups.push(() => rm(outside, { recursive: true, force: true })) + const { ctx } = await boot() + const session = ctx.sessions.create(SessionId('nested'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + await writeFile(join(root, 'a.txt'), 'changed\n') + await writeFile(join(cwd, 'inner.txt'), 'inner\n') + toolCall(session, 1, 'write', { file_path: join(outside, 'note.txt') }, { + meta: { diffs: [{ path: join(outside, 'note.txt'), oldText: null, newText: 'one\ntwo\nthree\n' }] }, + }) + endTurn(session, 1, 'blocked') + await settle(ctx, session) + const [recorded] = changes(session) + expect(recorded!.files).toEqual([ + { path: join(await realpath(root), 'a.txt'), display: '../a.txt', added: 1, deleted: 3 }, + { path: 'inner.txt', display: 'inner.txt', added: 1, deleted: 0 }, + { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 3, deleted: 0 }, + ]) + }) + + it('records inside the turn when the agent stops, and again after turn/end only when tools settled later', async () => { + const cwd = await repository() + const { ctx } = await boot() + const session = ctx.sessions.create(SessionId('stopping'), { meta: { cwd } }) + const agent = { session } as never + const signal = new AbortController().signal + startTurn(session, 1) + await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal }) + expect(changes(session)).toEqual([]) + await writeFile(join(cwd, 'one.txt'), '1\n') + toolCall(session, 1, 'bash', { command: 'x' }) + await ctx.serial('agent/turn-stopping', { agent, turn: 7, signal }) + await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal }) + const inTurn = session.snapshotEvents().find(event => event.type === 'workspace/changes') + expect(inTurn?.data).toMatchObject({ turn: 1, total: 1 }) + endTurn(session, 1) + await settle(ctx, session) + expect(changes(session)).toHaveLength(1) + expect(session.snapshotEvents().find(event => event.type === 'turn/end')!.seq).toBeGreaterThan(inTurn!.seq) + + startTurn(session, 2) + await settle(ctx, session) + await writeFile(join(cwd, 'two.txt'), '2\n') + toolCall(session, 2, 'bash', { command: 'x' }) + await ctx.serial('agent/turn-stopping', { agent, turn: 2, signal }) + await rm(join(cwd, 'two.txt')) + toolCall(session, 2, 'bash', { command: 'steered' }) + endTurn(session, 2) + await settle(ctx, session) + const second = changes(session).filter(data => data.turn === 2) + expect(second.map(data => data.files.map(file => file.path))).toEqual([['two.txt'], []]) + expect(second[1]).toMatchObject({ total: 0 }) + + startTurn(session, 3) + await settle(ctx, session) + toolCall(session, 3, 'read', { file_path: 'a.txt' }) + endTurn(session, 3) + await settle(ctx, session) + expect(changes(session).filter(data => data.turn === 3)).toEqual([]) + }) + + it('caps the file list while reporting the complete count', async () => { + const cwd = await repository() + const { ctx } = await boot({ maxFiles: 2 }) + const session = ctx.sessions.create(SessionId('capped'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + for (const name of ['c.txt', 'd.txt', 'e.txt']) await writeFile(join(cwd, name), `${name}\n`) + toolCall(session, 1, 'bash', { command: 'x' }) + toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l1\n', newText: 'l1\n' }] } }) + endTurn(session, 1) + await settle(ctx, session) + const [recorded] = changes(session) + expect(recorded!.total).toBe(3) + expect(recorded!.files.map(file => file.display)).toEqual(['c.txt', 'd.txt']) + }) + + it('warns and skips the turn when its git work fails', async () => { + const cwd = await scratchDir('dsh-workspace-changes-warn-', cleanups) + const blocker = join(cwd, 'blocker') + await writeFile(blocker, 'not a directory') + const { ctx } = await boot({ dshHome: blocker }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const session = ctx.sessions.create(SessionId('warn'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + await writeFile(join(cwd, 'x.txt'), 'x\n') + toolCall(session, 1, 'bash', { command: 'x' }) + endTurn(session, 1) + await settle(ctx, session) + expect(changes(session)).toEqual([]) + const own = warn.mock.calls.map(call => String(call[0])).filter(message => message.startsWith('workspace-changes:')) + expect(own).toHaveLength(1) + expect(own[0]).toContain('ENOTDIR') + }) + + it('rejects non-positive bounds at load', async () => { + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(LocalSubprocessRuntime) + await expect(ctx.plugin(WorkspaceChanges, { maxFiles: 0 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer maxFiles') + }) +}) + +describe('workspace-changes without a repository', () => { + it('snapshots into a shadow repository under the Harness home and excludes that home', async () => { + const cwd = await scratchDir('dsh-workspace-changes-plain-', cleanups) + const dshHome = join(cwd, '.dsh') + await writeFile(join(cwd, 'existing.txt'), 'before\n') + const { ctx } = await boot({ dshHome }) + const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + await writeFile(join(cwd, 'existing.txt'), 'after\nmore\n') + await mkdir(join(cwd, 'node_modules'), { recursive: true }) + await writeFile(join(cwd, 'node_modules', 'dep.js'), 'module\n') + await writeFile(join(dshHome, 'settings.yaml'), 'changed: true\n') + toolCall(session, 1, 'write', { file_path: 'node_modules/dep.js' }, { + meta: { diffs: [{ path: 'node_modules/dep.js', oldText: null, newText: 'module\n' }] }, + }) + endTurn(session, 1) + await settle(ctx, session) + const [first] = changes(session) + expect(first!.files).toEqual([ + { path: 'existing.txt', display: 'existing.txt', added: 2, deleted: 1 }, + { path: 'node_modules/dep.js', display: 'node_modules/dep.js', added: 1, deleted: 0 }, + ]) + const shadows = join(dshHome, 'workspace-changes') + expect((await stat(shadows)).isDirectory()).toBe(true) + await expect(stat(join(cwd, '.git'))).rejects.toThrow() + + startTurn(session, 2) + await settle(ctx, session) + await writeFile(join(cwd, 'second.txt'), 's\n') + toolCall(session, 2, 'bash', { command: 'x' }) + endTurn(session, 2) + await settle(ctx, session) + expect(changes(session).at(-1)!.files.map(file => file.display)).toEqual(['second.txt']) + }) + + it('drops a disposed session’s recorder and starts afresh on its next turn', async () => { + const cwd = await scratchDir('dsh-workspace-changes-disposed-', cleanups) + const { ctx, fiber } = await boot() + const session = ctx.sessions.create(SessionId('disposed'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + ctx.emit('session/disposed', session) + await writeFile(join(cwd, 'one.txt'), '1\n') + toolCall(session, 1, 'bash', { command: 'x' }) + endTurn(session, 1) + await settle(ctx, session) + expect(changes(session)).toEqual([]) + startTurn(session, 2) + await settle(ctx, session) + await writeFile(join(cwd, 'two.txt'), '2\n') + toolCall(session, 2, 'bash', { command: 'x' }) + endTurn(session, 2) + await settle(ctx, session) + expect(changes(session).map(data => data.files.map(file => file.display))).toEqual([['two.txt']]) + + startTurn(session, 3) + await settle(ctx, session) + await fiber.dispose() + await writeFile(join(cwd, 'three.txt'), '3\n') + toolCall(session, 3, 'bash', { command: 'x' }) + endTurn(session, 3) + await settle(ctx, session) + expect(changes(session).filter(data => data.turn === 3)).toEqual([]) + }) + + it('ignores subagent sessions and sessions without a working directory', async () => { + const cwd = await scratchDir('dsh-workspace-changes-skip-', cleanups) + const { ctx } = await boot() + const sessions = [ + ctx.sessions.create(SessionId('child'), { meta: { cwd, delegationDepth: 1 } }), + ctx.sessions.create(SessionId('origin'), { meta: { cwd, origin: 'subagent' } }), + ctx.sessions.create(SessionId('nowhere')), + ] + for (const session of sessions) { + startTurn(session, 1) + await settle(ctx, session) + toolCall(session, 1, 'bash', { command: 'x' }) + endTurn(session, 1) + await settle(ctx, session) + expect(changes(session)).toEqual([]) + } + await ctx.waterfall('tools/pre-execute', {} as never, () => Promise.resolve(undefined as never)) + }) +}) + +describe('workspace-changes without git', () => { + it('records nothing and reports the absence once', async () => { + const cwd = await scratchDir('dsh-workspace-changes-nogit-', cleanups) + const { ctx } = await boot() + vi.spyOn(ctx.subprocess, 'resolveExecutable').mockRejectedValue(new Error('git: not found')) + const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined) + const session = ctx.sessions.create(SessionId('nogit'), { meta: { cwd } }) + for (const turn of [1, 2]) { + startTurn(session, turn) + await settle(ctx, session) + await writeFile(join(cwd, `${turn}.txt`), 'x\n') + toolCall(session, turn, 'bash', { command: 'x' }) + endTurn(session, turn) + await settle(ctx, session) + } + expect(changes(session)).toEqual([]) + expect(info).toHaveBeenCalledTimes(1) + expect(info.mock.calls[0]![0]).toContain('git is unavailable') + }) + + it('treats the macOS developer-tools stub as absent until a developer directory is selected', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + cleanups.push(async () => { Object.defineProperty(process, 'platform', platform) }) + const probes: Array<{ done: Promise<{ exitCode: number | null; signal: null }>; available: boolean; executable?: string }> = [ + { done: Promise.reject(new Error('spawn failed')), available: false }, + { done: Promise.resolve({ exitCode: 1, signal: null }), available: false }, + { done: Promise.resolve({ exitCode: 0, signal: null }), available: true }, + { done: Promise.resolve({ exitCode: 1, signal: null }), available: true, executable: '/opt/homebrew/bin/git' }, + ] + for (const probe of probes) { + probe.done.catch(() => undefined) + const cwd = await scratchDir('dsh-workspace-changes-stub-', cleanups) + const { ctx } = await boot() + vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(probe.executable ?? '/usr/bin/git') + const real = ctx.subprocess.spawn.bind(ctx.subprocess) + const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(spec => + spec.argv[0] === '/usr/bin/xcode-select' ? { done: probe.done } as never : real(spec)) + const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined) + vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const session = ctx.sessions.create(SessionId('stub'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + expect(spawn.mock.calls.some(call => call[0].argv[0] === '/usr/bin/xcode-select')).toBe(probe.executable === undefined) + expect(info).toHaveBeenCalledTimes(probe.available ? 0 : 1) + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/fs/workspace-changes/tests/support.ts b/packages/fs/workspace-changes/tests/support.ts new file mode 100644 index 0000000000..cd7d399d76 --- /dev/null +++ b/packages/fs/workspace-changes/tests/support.ts @@ -0,0 +1,67 @@ +/** Shared fixtures: temporary git repositories, logged turns, and event readers. */ +import { execFileSync } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import { ToolCallId, createAssistantMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' +import type { Session } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-tools' +import type { WorkspaceChangesData } from '../src/types.ts' + +let callNumber = 0 + +/** Run git synchronously inside a fixture repository. */ +export function git(cwd: string, ...args: string[]): string { + return execFileSync('git', ['-c', 'user.email=t@example.com', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { + cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }) +} + +/** A temporary directory removed by the returned cleanup. */ +export async function scratchDir(prefix: string, cleanups: Array<() => Promise>): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +/** Open a turn with its first step. */ +export function startTurn(session: Session, turn: number): void { + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) +} + +/** Log one settled tool call with an optional result `meta`. */ +export function toolCall(session: Session, turn: number, name: string, args: unknown, result: { meta?: unknown; isError?: boolean } = {}) { + const callId = ToolCallId(`call-${++callNumber}`) + const serialized = JSON.stringify(args) + session.append('assistant/message', { + stream: [], turn, step: 1, + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: callId, name, arguments: serialized }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + const source = session.append('tool/call', { turn, step: 1, callId, name, arguments: serialized }) + return session.append('tool/result', { + turn, step: 1, + message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'ok' }], isError: result.isError ?? false }), + ...result.meta === undefined ? {} : { meta: result.meta as never }, + }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) +} + +/** Close the step and the turn. */ +export function endTurn(session: Session, turn: number, reason: 'completed' | 'blocked' = 'completed'): void { + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: reason } }) +} + +/** Wait for the plugin's queued git work through the same waterfall tool execution uses. */ +export async function settle(ctx: Context, session: Session): Promise { + await ctx.waterfall('tools/pre-execute', { agent: { session } } as never, () => Promise.resolve(undefined as never)) +} + +/** Recorded change events of one session. */ +export function changes(session: Session): WorkspaceChangesData[] { + return session.snapshotEvents().filter(event => event.type === 'workspace/changes').map(event => event.data) +} diff --git a/packages/fs/workspace-changes/tsconfig.json b/packages/fs/workspace-changes/tsconfig.json new file mode 100644 index 0000000000..b0d5795f1d --- /dev/null +++ b/packages/fs/workspace-changes/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../util/home-paths" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2773904c8..c6c3966c6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -616,6 +616,9 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local + '@deepseek-ai/dsh-workspace-changes': + specifier: workspace:^ + version: link:../../packages/fs/workspace-changes '@types/node': specifier: ^22.0.0 version: 22.20.0 @@ -1985,6 +1988,9 @@ importers: '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace + '@deepseek-ai/dsh-workspace-changes': + specifier: workspace:^ + version: link:../../fs/workspace-changes '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -2770,6 +2776,9 @@ importers: '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path + '@deepseek-ai/dsh-workspace-changes': + specifier: workspace:^ + version: link:../../fs/workspace-changes '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -6258,6 +6267,46 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/fs/workspace-changes: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + diff: + specifier: ^9.0.0 + version: 9.0.0 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/goal/command-goal: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 448f3be1da..1b335bc635 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' }, 'packages/util/deque': { kind: 'none', reason: 'In-process collection primitive; registers nothing model-facing.' }, + 'packages/fs/workspace-changes': { kind: 'none', reason: 'The recorder appends a log-only Session event that only clients read; it registers nothing model-facing.' }, 'packages/util/chunked-list': { kind: 'none', reason: 'Immutable collection primitive; registers nothing model-facing.' }, 'packages/util/package-manifest': { kind: 'none', reason: 'Type declarations only; registers nothing model-facing.' }, 'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 245d733e82..c5c515a2a8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -457,7 +457,8 @@ "@deepseek-ai/dsh-workflow/invariant": ["./packages/workflow/workflow/src/invariant.ts"], "@deepseek-ai/dsh-workflow-worker-thread": ["./packages/workflow/workflow-worker-thread/src"], "@deepseek-ai/dsh-workspace": ["./packages/workspace/workspace/src"], - "@deepseek-ai/dsh-workspace/invariant": ["./packages/workspace/workspace/src/invariant.ts"] + "@deepseek-ai/dsh-workspace/invariant": ["./packages/workspace/workspace/src/invariant.ts"], + "@deepseek-ai/dsh-workspace-changes": ["./packages/fs/workspace-changes/src"] // END generated package aliases } } diff --git a/tsconfig.host.json b/tsconfig.host.json index c5a3f118b3..b4af1b1a91 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -83,7 +83,7 @@ "apps/web/tests/feedback-release.e2e.ts", "apps/web/tests/goal-command-presentation.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", - "apps/web/tests/produced-files.e2e.ts", + "apps/web/tests/changed-files.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/subagent-interrupt.e2e.ts", @@ -273,6 +273,7 @@ { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-present" }, + { "path": "./packages/fs/workspace-changes" }, { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/fs/tool-str-replace-editor" }, { "path": "./packages/compaction/compaction" }, From 0f8457a017e3ff1ebbeff3e2e58b49bd57486da1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 17:22:15 +0800 Subject: [PATCH 03/37] feat(workspace-changes): record only git repositories and add the recorded card scenario Drop the shadow repository tier: a working directory outside any git repository records nothing and the card stays absent, because a configured exclude list cannot replace a missing .gitignore reliably. Seed a committed repository in a new keyless Web scenario so the recorded turn exercises the recorder, and retire the produced-files row from the affected goldens. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 6 +- .../2026-09-11-turn-changed-files-card.zh.md | 6 +- apps/web/tests/changed-files-turn.e2e.ts | 118 ++++++++++++++++ apps/web/tests/changed-files-turn.overlay.yml | 5 + apps/web/tests/clickable-links-gallery.e2e.ts | 16 +-- .../clickable-links-gallery/ui.expected.md | 8 -- apps/web/tests/present-svg.e2e.ts | 5 +- apps/web/tests/produced-file-mentions.e2e.ts | 4 +- apps/web/tests/scaffold.ts | 19 +-- apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../fs/workspace-changes/README.i18n.yaml | 4 +- packages/fs/workspace-changes/README.md | 15 +- packages/fs/workspace-changes/README.zh.md | 15 +- packages/fs/workspace-changes/package.json | 2 - packages/fs/workspace-changes/src/git.ts | 82 +++-------- packages/fs/workspace-changes/src/index.ts | 24 +--- packages/fs/workspace-changes/src/recorder.ts | 9 +- .../fs/workspace-changes/tests/git.spec.ts | 26 ++-- .../tests/loader-composition.spec.ts | 2 - .../fs/workspace-changes/tests/plugin.spec.ts | 48 ++----- packages/fs/workspace-changes/tsconfig.json | 3 - pnpm-lock.yaml | 3 - .../web/changed-files-turn/session.v3.jsonl | 43 ++++++ snapshots/web/changed-files-turn/snapshot.yml | 9 ++ .../web/changed-files-turn/ui.expected.md | 133 ++++++++++++++++++ .../workspace.expected/intro.md | 3 + .../workspace.expected/notes.txt | 2 + .../workspace.expected/src/util.ts | 6 + snapshots/web/present-svg/ui.expected.md | 2 - tsconfig.host.json | 1 + 37 files changed, 427 insertions(+), 225 deletions(-) create mode 100644 apps/web/tests/changed-files-turn.e2e.ts create mode 100644 apps/web/tests/changed-files-turn.overlay.yml create mode 100644 snapshots/web/changed-files-turn/session.v3.jsonl create mode 100644 snapshots/web/changed-files-turn/snapshot.yml create mode 100644 snapshots/web/changed-files-turn/ui.expected.md create mode 100644 snapshots/web/changed-files-turn/workspace.expected/intro.md create mode 100644 snapshots/web/changed-files-turn/workspace.expected/notes.txt create mode 100644 snapshots/web/changed-files-turn/workspace.expected/src/util.ts diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index fbb4412b0d..d7567cc4df 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: f04e694853a89be75c642325d636c6a8fd496ddb -2026-09-11-turn-changed-files-card.zh.md: 52474eaa4dc4b312dadb475c590e25146944c922 +2026-09-11-turn-changed-files-card.md: c477935fad1fa95ae0d60a67f31a89c38b020087 +2026-09-11-turn-changed-files-card.zh.md: 23ee760f18d6f9a6e9b540ef0e9bde452968a308 diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index f04e694853..c477935fad 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -14,7 +14,7 @@ The Host [workspace-changes](../../../../packages/fs/workspace-changes/README.md The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it. -Git is the default executable on `PATH`; no environment plugin is consulted. Three tiers cover the workspace: a working directory inside a repository uses that repository; a working directory outside any repository uses a shadow repository under the Harness home, with its git directory there and its work tree pointing at the working directory, so the user's directory gains no `.git`; without git the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. Shadow repositories take their excludes from configuration because no `.gitignore` exists there. +Git is the default executable on `PATH`; no environment plugin is consulted. Only a working directory inside a git repository is recorded; outside any repository, or without git, the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, so no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. @@ -36,10 +36,12 @@ The recorder appends inside the turn on `agent/turn-stopping` and again after `t **An environment-provider seam for locating git** was raised by the team but not settled; the plugin uses `PATH` and keeps its lookup in one place. +**A shadow repository for working directories outside any repository** — a git directory under the Harness home with the work tree pointing at the working directory — would give those users the card without adding a `.git`, but a shadow repository has no `.gitignore`, and a configured exclude list cannot reliably keep build outputs, caches, and dependency trees out of every project layout. It is deferred until that exclude policy is settled; the recorder already treats the repository as an input, so adding the tier changes only where the snapshot goes. + ## Consequences Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the repository's object store; unreferenced trees are pruned by the repository's own garbage collection, after which only the recorded counts remain. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. -The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. +The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card only when their workspace is a git repository, which one dedicated scenario seeds. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. Focused tests cover repository and shadow tiers with real git, coverage classification, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenarios replay the recorder end to end. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index 52474eaa4d..23ee760f18 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -14,7 +14,7 @@ Host 侧的 [workspace-changes](../../../../packages/fs/workspace-changes/README 记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。 -git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。工作区分三档:位于仓库内的工作目录使用该仓库;不在任何仓库内的工作目录使用 Harness home 下的影子仓库,其 git 目录在那里而工作树指向工作目录,用户目录里不会多出 `.git`;没有 git 时插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。影子仓库的排除规则来自配置,因为那里没有 `.gitignore`。 +git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。 快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,因此不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 @@ -36,10 +36,12 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 **用于定位 git 的环境提供者接缝**由团队提出但尚未定案;插件使用 `PATH` 并把查找收敛在一处。 +**为不在任何仓库内的工作目录建影子仓库**,即把 git 目录放在 Harness home 下、工作树指向工作目录,可以让这些用户在不多出 `.git` 的情况下看到卡片,但影子仓库没有 `.gitignore`,一份配置的排除列表无法在所有项目布局里可靠地挡住构建产物、缓存和依赖目录。它暂缓到排除策略定下来为止;记录器已经把仓库当作输入,加这一档只改变快照写到哪里。 + ## Consequences 每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入仓库对象库;无引用的树由仓库自己的垃圾回收清理,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 -只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景新增该事件与卡片。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 +只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只有在工作区是 git 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 聚焦测试用真实 git 覆盖仓库与影子两档、覆盖范围分类、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器。 diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts new file mode 100644 index 0000000000..3074805be7 --- /dev/null +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -0,0 +1,118 @@ +/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card. */ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type {} from '@deepseek-ai/dsh-workspace-changes' +import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import { + assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts' + +const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url)) +const FIXTURE = join(DIR, 'session.v3.jsonl') +const MODE = webSnapshotMode() +const PROMPT = '不用先查看目录,直接做三件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,然后用 bash 在 notes.txt 末尾追加一行 done。' + +/** Seed a committed repository so the turn's own edits are the only difference between its snapshots. */ +async function seedRepository(cwd: string): Promise { + await mkdir(cwd, { recursive: true }) + await writeFile(join(cwd, 'intro.md'), '# 示例项目\n\n一个用于演示的仓库。\n') + await writeFile(join(cwd, 'notes.txt'), 'start\n') + const git = (...args: string[]) => execFileSync('git', ['-c', 'user.email=seed@example.com', '-c', 'user.name=seed', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'ignore' }) + git('init', '-q', '-b', 'main') + git('add', '-A') + git('commit', '-q', '-m', 'seed') +} + +describe('web e2e: a git workspace turn ends with its changed files', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let cwd: string + let replayRoot: string | undefined + + beforeAll(async () => { + let replayOverride: string | undefined + if (MODE !== 'record') { + replayRoot = await mkdtemp(join(tmpdir(), 'dsh-changed-files-turn-replay-')) + replayOverride = join(replayRoot, 'replay.override.json') + const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8'))) + // Recorded absolute paths must follow each isolated Session's working directory. + const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}' + await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1))) + } + scaffold = await launchWebScaffold({ + compareReplaySession: true, + extraOverlayPath: fileURLToPath(new URL('./changed-files-turn.overlay.yml', import.meta.url)), + ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }), + }) + await seedRepository(join(scaffold.workspaceCwd, 'workspace')) + browser = await chromium.launch() + page = await browser.newPage({ + viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai', + }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]') + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + try { + await browser?.close() + } finally { + try { + await scaffold?.close() + } finally { + if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true }) + } + } + }) + + it('records the edited, created, and shell-appended files with their line counts', async () => { + if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + const session = scaffold.ctx.agents.get(sessionId)?.session + if (session?.header.cwd === undefined) throw new Error('changed-files Session has no workspace') + cwd = session.header.cwd + if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) + + const summaries = session.snapshotEvents().filter(event => event.type === 'workspace/changes') + const summary = summaries.at(-1) + expect(summary, 'the turn must record its changed files').toBeDefined() + if (summary === undefined) throw new Error('no changed-files summary') + expect(summary.data.files.map(file => file.display)).toEqual(['intro.md', 'notes.txt', 'src/util.ts']) + expect(summary.data.total).toBe(3) + for (const file of summary.data.files) expect(file.added).toBeGreaterThan(0) + expect(summary.data.files[1]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 }) + expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n') + + const card = page.locator('[data-changed-files]') + await card.waitFor({ state: 'visible' }) + expect(await card.getByText('已编辑 3 个文件', { exact: true }).count()).toBe(1) + expect(await card.getByRole('listitem').count()).toBe(3) + // Without a Host desktop the header is a label and rows preview in the Sidebar. + expect(await card.getByRole('button', { name: '打开改动文件所在的文件夹' }).count()).toBe(0) + expect(await card.getByRole('button', { name: '在侧边栏打开 notes.txt' }).count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it.skipIf(MODE === 'record')('replays the workspace and the Chinese conversation', async () => { + await assertFinalWorkspaceSnapshot(DIR, cwd, { ignoredRootEntries: ['.git'] }) + const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) + }) +}) diff --git a/apps/web/tests/changed-files-turn.overlay.yml b/apps/web/tests/changed-files-turn.overlay.yml new file mode 100644 index 0000000000..4c89d7cd1d --- /dev/null +++ b/apps/web/tests/changed-files-turn.overlay.yml @@ -0,0 +1,5 @@ +# This transcript includes the no-desktop state on every test platform, so the +# card header stays a label and rows preview in the Sidebar. +- id: session-controller + config: + nativeOpen: false diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 081d183c5b..8d5ce306d5 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -41,7 +41,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui // The golden holds the show-in-folder affordance; pin the native-opener // capability so headless Linux CI and desktop developer hosts expose the same // UI branch (same pin as produced-files.e2e.ts, whose overlay this shares). -const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.meta.url)) +const OVERLAY = fileURLToPath(new URL('./changed-files.overlay.yml', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'clickable-links-gallery-web-e2e' const DONE = 'LINK_GALLERY_DONE' @@ -343,13 +343,12 @@ describe('web e2e: clickable links gallery', () => { expect(await markdown.locator(`img[src="${imageUrl}"]`).count()).toBe(1) // Produced files: one unique-basename mention links; the shared basename - // and the unwritten file stay inert code. Seven produced paths overflow - // the chip row; the failed write joins neither surface. + // and the unwritten file stay inert code. The seeded session carries no + // recorded change summary, so no changed-files card follows the prose. const mentions = markdown.locator('code button') expect(await mentions.count()).toBe(1) expect(await mentions.first().getAttribute('title')).toBe('site/report.html') - expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1) - expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5) + expect(await page.locator('[data-changed-files]').count()).toBe(0) expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0) // Tool rows: five writes, the edit, and the read carry the dotted file @@ -406,13 +405,11 @@ describe('web e2e: clickable links gallery', () => { const styleOf = async (target: ReturnType, property: string): Promise => target.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), property) const guideLink = markdown.locator(`a[href="${GUIDE_URL}"]`).first() - const chip = page.locator('button[aria-label="Open site/report.html"]').first() for (const [name, link] of [ ['markdown anchor', guideLink], ['file mention', mentions.first()], ['search source', sourceLink.first()], ['fetch url', page.locator(`a[href="${FETCH_URL}"]`).first()], - ['produced chip', chip], ] as const) { expect.soft(await styleOf(link, 'color'), `${name} color`).toBe(LINK_BLUE) expect.soft(await styleOf(link, 'font-weight'), `${name} weight`).toBe('500') @@ -423,9 +420,8 @@ describe('web e2e: clickable links gallery', () => { expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline') expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted') expect(await styleOf(guideLink, 'text-underline-offset')).toBe('3px') - await chip.hover() - expect(await styleOf(chip, 'text-decoration-style')).toBe('dotted') - expect(await styleOf(chip, 'background-color')).toBe('rgba(0, 0, 0, 0)') + await mentions.first().hover() + expect(await styleOf(mentions.first(), 'text-decoration-style')).toBe('dotted') // The excluded grey affordance: tool-row file links keep their own color. expect(await styleOf(page.locator('button[class*="fileLink"]').first(), 'color')).not.toBe(LINK_BLUE) }, 90_000) diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 07b3a91ce1..d592386f9f 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -169,14 +169,6 @@ - list: - listitem: - paragraph: Footnote references stay inert superscripts. ↩ -- text: Files changed -- button "Open site/report.html": report.html -- button "Open a/style.css": style.css -- button "Open b/style.css": style.css -- button "Open site/index.html": index.html -- button "Open site/app.js": app.js -- button "Open src/tokens.css": tokens.css -- text: + 1 file - button "Copy": - img - button "Good response": diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 9fc93f131b..ad56cbe48b 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -106,9 +106,8 @@ describe('web e2e: requested SVG is explicitly delivered', () => { await card.waitFor({ state: 'visible' }) expect(await card.count()).toBe(1) expect(await page.getByText('产物', { exact: true }).count()).toBe(0) - if (await page.locator('[data-produced-files-row]').count() > 0) { - expect(await page.getByText('本轮文件改动', { exact: true }).count()).toBe(1) - } + // The scaffold workspace is not a git repository, so no changed-files card accompanies the delivery. + expect(await page.locator('[data-changed-files]').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index e270781f49..987bffe1dd 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -151,8 +151,8 @@ describe('web e2e: inline-code mentions of produced files', () => { expect(await mentions.first().innerText()).toBe('report.html') expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html in sidebar') expect(await mentions.first().getAttribute('title')).toBe('site/report.html') - // The turn still ends with its produced-files row (all three writes). - expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1) + // Mentions resolve from the mutation calls alone; without a recorded summary no card follows. + expect(await page.locator('[data-changed-files]').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 30173daff8..f56df34189 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -38,6 +38,7 @@ import Group from '@deepseek-ai/cordis-plugin-group' import { captureExpectedWorkspaceSnapshot, captureWorkspaceSnapshot, + type CaptureWorkspaceSnapshotOptions, assertSessionFixtureVersion, formatSystemPromptSnapshot, formatToolSchemasSnapshot, @@ -64,7 +65,6 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' -import { DEFAULT_SHADOW_EXCLUDES } from '@deepseek-ai/dsh-workspace-changes' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, RetryPolicyConfig, StreamChunk, @@ -128,13 +128,16 @@ export function webSnapshotMode(): WebSnapshotMode { * Compare a session-driven Web scenario's complete workspace with its committed independent expected state. * @param scenarioDir - Absolute recorded-session scenario directory. * @param workspaceRoot - Absolute cwd used by the controlled session. + * @param options - Root entries the scenario owns outside the expected state, such as a `.git` directory it initialized. */ -export async function assertFinalWorkspaceSnapshot(scenarioDir: string, workspaceRoot: string): Promise { +export async function assertFinalWorkspaceSnapshot( + scenarioDir: string, workspaceRoot: string, options: CaptureWorkspaceSnapshotOptions = {}, +): Promise { const manifestPath = join(scenarioDir, 'snapshot.yml') const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath) expect(manifest.workspace?.final, `${manifest.scenario ?? scenarioDir}: mutating Web scenario declares workspace.final`) .toBe(true) - const actual = await captureWorkspaceSnapshot(workspaceRoot) + const actual = await captureWorkspaceSnapshot(workspaceRoot, options) const expected = await captureExpectedWorkspaceSnapshot(join(scenarioDir, 'workspace.expected')) expect(actual, `${manifest.scenario ?? scenarioDir}: complete final workspace`).toEqual(expected) } @@ -606,16 +609,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ## Use this package -Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../fs/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a recorded summary — no git on the Host, or the plugin composed out — the card is absent and only deliveries and prose links remain. +Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../fs/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a recorded summary — a workspace outside any git repository, no git on the Host, or the plugin composed out — the card is absent and only deliveries and prose links remain. ### Explicit deliveries diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index a7db3227b5..609a82070b 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../fs/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有记录的摘要时——Host 上没有 git,或该插件被组合出去——卡片不出现,只保留交付卡片与正文链接。 +与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../fs/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有记录的摘要时——工作区不在任何 git 仓库内、Host 上没有 git,或该插件被组合出去——卡片不出现,只保留交付卡片与正文链接。 ### 显式交付 diff --git a/packages/fs/workspace-changes/README.i18n.yaml b/packages/fs/workspace-changes/README.i18n.yaml index cd0372680b..2358e470e9 100644 --- a/packages/fs/workspace-changes/README.i18n.yaml +++ b/packages/fs/workspace-changes/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/fs/workspace-changes/README.md -README.md: c5b83477ad986e8c4cecf403285ad8e1a7106fb7 -README.zh.md: 75d96e9a769412946eb788874564cc2f7b85bdf7 +README.md: 0c25dbacb039c32cc7cb7f3cb3ec57e251cc3de3 +README.zh.md: 1e400e422c87f9b9cd86c532ffdf1143daf92717 diff --git a/packages/fs/workspace-changes/README.md b/packages/fs/workspace-changes/README.md index c5b83477ad..0c25dbacb0 100644 --- a/packages/fs/workspace-changes/README.md +++ b/packages/fs/workspace-changes/README.md @@ -1,5 +1,5 @@ --- -description: "Record each top-level turn's changed files from git working-tree snapshots as a durable workspace/changes Session event; configuration, repository tiers, and coverage rules." +description: "Record each top-level turn's changed files from git working-tree snapshots as a durable workspace/changes Session event; configuration, repository requirement, and coverage rules." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This plugin records which files each top-level turn changed, with per-file added and deleted line counts, as a `workspace/changes` Session event. It snapshots the working tree with git when a turn starts and when it ends, diffs the two snapshots, and adds the file-tool edits that git does not cover. The Web changed-files card renders the event; the model never sees it. +This plugin records which files each top-level turn changed, with per-file added and deleted line counts, as a `workspace/changes` Session event. It snapshots the working tree with git when a turn starts and when it ends, diffs the two snapshots, and adds the file-tool edits that git does not cover. Only a working directory inside a git repository is recorded. The Web changed-files card renders the event; the model never sees it. ## Table of Contents @@ -35,15 +35,13 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the | Field | Default | Meaning | |---|---|---| -| `dshHome` | `$DSH_HOME`, then `~/.dsh` | Harness home whose `workspace-changes/` directory holds shadow repositories | | `timeoutMs` | `30000` | Milliseconds one git command may run before the turn's record is abandoned | | `outputMaxBytes` | `8388608` | Bytes of git output retained per command; a larger diff listing abandons the record | | `maxFiles` | `500` | Maximum files carried by one event; `total` still reports the complete count | -| `shadowExcludes` | `.git/`, `.dsh/`, `node_modules/`, build and cache directories | `info/exclude` patterns for shadow repositories | -Every Session with a working directory and no subagent origin is recorded; subagent Sessions are not. A working directory inside a git repository snapshots into that repository's object store through a private index, so the repository's own index, work tree, and refs stay untouched and the user's earlier uncommitted changes never enter a summary. A working directory outside any repository snapshots into a shadow repository under the Harness home whose work tree is the working directory itself; the `shadowExcludes` patterns replace the missing `.gitignore`, and a Harness home inside the working directory is excluded automatically. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once. +Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots go into the repository's object store through a private index, so the repository's own index, work tree, and refs stay untouched and the user's earlier uncommitted changes never enter a summary. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once. -Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results: files matching an ignore pattern and files outside the repository or shadow work tree. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the working directory. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded. +Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results: files matching an ignore pattern and files outside the repository. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the working directory. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded. Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The event also carries the two snapshot tree ids. @@ -55,7 +53,7 @@ Each file carries a durable `path` — relative to the working directory inside
Implementation internals — click to expand -One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository or initializes the shadow one, then `add --all` into a temporary index seeded from the persisted index and `write-tree` produce the tree id. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. A shadow repository keeps its refreshed index for the next snapshot; a real repository's index is only read. +One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository, then `add --all` into a temporary index seeded from the repository's index and `write-tree` produce the tree id. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. The repository's index is only read. Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work. @@ -70,7 +68,7 @@ Git runs through the `subprocess` capability with a scrubbed environment, `GIT_T - [Web deliverables](../../client/ui-deliverables/README.md) — the changed-files card that renders this event and opens its files. - [Subprocess capability](../../subprocess/README.md) — the seam git runs through. -- [Turn changed-files card decision](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — snapshot design, coverage rules, and rejected alternatives. +- [Turn changed-files card decision](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — snapshot design, coverage rules, the deferred shadow repository, and rejected alternatives. ## Model Experience @@ -87,6 +85,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected. - Snapshot trees are unreferenced objects, so a repository's garbage collection removes them after its prune expiry; the recorded counts survive, the trees needed for a later full-file comparison do not. - Edits the user makes during a turn are attributed to that turn. +- A working directory outside any git repository has no card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably. - Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only. - Windows paths keep native separators in `path`; `display` is always slash-separated. diff --git a/packages/fs/workspace-changes/README.zh.md b/packages/fs/workspace-changes/README.zh.md index 75d96e9a76..1e400e422c 100644 --- a/packages/fs/workspace-changes/README.zh.md +++ b/packages/fs/workspace-changes/README.zh.md @@ -1,5 +1,5 @@ --- -description: "用 git 工作树快照把每个顶层轮次改动的文件记录为持久的 workspace/changes Session 事件;配置、仓库分档与覆盖规则。" +description: "用 git 工作树快照把每个顶层轮次改动的文件记录为持久的 workspace/changes Session 事件;配置、仓库要求与覆盖规则。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本插件把每个顶层轮次改动了哪些文件、每个文件的增删行数记录为 `workspace/changes` Session 事件。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。Web 的改动文件卡片渲染该事件;模型看不到它。 +本插件把每个顶层轮次改动了哪些文件、每个文件的增删行数记录为 `workspace/changes` Session 事件。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。只有位于 git 仓库内的工作目录会被记录。Web 的改动文件卡片渲染该事件;模型看不到它。 ## 目录 @@ -35,15 +35,13 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `dshHome` | `$DSH_HOME`,其次 `~/.dsh` | Harness home,其 `workspace-changes/` 目录存放影子仓库 | | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 | | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 | | `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 | -| `shadowExcludes` | `.git/`、`.dsh/`、`node_modules/`、构建与缓存目录 | 影子仓库的 `info/exclude` 模式 | -每个有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。工作目录位于 git 仓库内时,快照通过私有 index 写入该仓库的对象库,仓库自己的 index、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。工作目录不在任何仓库内时,快照写入 Harness home 下的影子仓库,其工作树就是该工作目录;`shadowExcludes` 模式代替缺失的 `.gitignore`,位于工作目录内的 Harness home 会被自动排除。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 +工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入仓库的对象库,仓库自己的 index、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 -文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库或影子工作树之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。 +文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。 每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。事件还携带两次快照的 tree id。 @@ -55,7 +53,7 @@ kind: "package-reference"
实现细节——点击展开 -每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库或初始化影子仓库,然后以持久 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。影子仓库保留刷新后的 index 供下次快照使用;真实仓库的 index 只读取。 +每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库,然后以仓库 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。仓库的 index 只读取。 git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作。 @@ -70,7 +68,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ - [Web 产出物](../../client/ui-deliverables/README.zh.md)——渲染该事件并打开其文件的改动文件卡片。 - [子进程能力](../../subprocess/README.zh.md)——git 运行所经过的接缝。 -- [本轮改动文件卡片决策](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计、覆盖规则与被否决的备选方案。 +- [本轮改动文件卡片决策](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计、覆盖规则、暂缓的影子仓库与被否决的备选方案。 ## 模型体验 @@ -87,6 +85,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ - 快照树是无引用对象,仓库的垃圾回收会在其过期期限后删除它们;记录的计数保留,而将来做整文件对比所需的树不会保留。 - 用户在轮次进行中自己做的编辑会被算到该轮。 +- 不在任何 git 仓库内的工作目录没有卡片;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。 - 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。 - Windows 路径在 `path` 中保留原生分隔符;`display` 始终用斜杠分隔。 diff --git a/packages/fs/workspace-changes/package.json b/packages/fs/workspace-changes/package.json index dbcb8db079..bc40a763fd 100644 --- a/packages/fs/workspace-changes/package.json +++ b/packages/fs/workspace-changes/package.json @@ -38,7 +38,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^" @@ -48,7 +47,6 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/packages/fs/workspace-changes/src/git.ts b/packages/fs/workspace-changes/src/git.ts index c072907336..f4b4d23ad6 100644 --- a/packages/fs/workspace-changes/src/git.ts +++ b/packages/fs/workspace-changes/src/git.ts @@ -1,11 +1,9 @@ /** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */ -import { createHash } from 'node:crypto' -import { copyFile, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import { copyFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { join } from 'node:path' import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' import { parseNumstat, type NumstatEntry } from './numstat.ts' -import { isInside, toPosix } from './paths.ts' /** Milliseconds a git child gets to exit after termination starts; a fixed lifecycle constant. */ const TERMINATE_GRACE_MS = 2_000 @@ -90,64 +88,33 @@ function ok(result: GitRunResult, what: string): GitRunResult { return result } -/** - * The git repository a Session working directory snapshots into: the - * enclosing repository when one exists, otherwise a shadow repository whose - * git directory lives under the Harness home while its work tree is the - * working directory itself. - */ +/** The repository enclosing a Session working directory. */ export interface GitWorkspace { - kind: 'repository' | 'shadow' - /** Work-tree root: the repository top level, or the working directory for a shadow. */ + /** Repository top-level directory, the root every diff path is relative to. */ root: string /** Absolute git directory holding the index and objects. */ gitDir: string - /** Environment that addresses the repository for every command. */ - env: Readonly> -} - -/** Where and how shadow repositories are created. */ -export interface ShadowRepositoryOptions { - /** Directory that holds one shadow git directory per working directory. */ - home: string - /** `info/exclude` patterns written on every snapshot. */ - excludes: readonly string[] } /** - * Locate the repository for a working directory, creating the shadow - * repository when the directory is not inside one. + * Locate the repository enclosing a working directory. * @param git - command runner. * @param cwd - absolute Session working directory. - * @param shadow - shadow repository placement and excludes. * @param signal - cancellation. - * @returns the addressed repository. + * @returns the repository, or null when the directory is not inside one. */ -export async function locateGitWorkspace( - git: GitRunner, cwd: string, shadow: ShadowRepositoryOptions, signal: AbortSignal, -): Promise { +export async function locateGitWorkspace(git: GitRunner, cwd: string, signal: AbortSignal): Promise { const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, signal }) - if (found.exitCode === 0) { - const [root, gitDir] = found.stdout.split('\n') as [string, string] - return { kind: 'repository', root, gitDir, env: {} } - } - const gitDir = join(shadow.home, createHash('sha256').update(cwd).digest('hex').slice(0, 16)) - const env = { GIT_DIR: gitDir, GIT_WORK_TREE: cwd } - await mkdir(gitDir, { recursive: true }) - ok(await git.run(['init', '-q'], { cwd, env, signal }), `git init of shadow repository ${gitDir}`) - const excludes = [...shadow.excludes] - if (isInside(cwd, shadow.home)) excludes.push(`/${toPosix(relative(cwd, shadow.home))}/`) - await mkdir(join(gitDir, 'info'), { recursive: true }) - await writeFile(join(gitDir, 'info', 'exclude'), `${excludes.join('\n')}\n`) - return { kind: 'shadow', root: cwd, gitDir, env } + if (found.exitCode !== 0) return null + const [root, gitDir] = found.stdout.split('\n') as [string, string] + return { root, gitDir } } /** * Write the complete work tree, including untracked and modified files but - * not ignored ones, as a tree object through a private index. The - * repository's own index seeds the stat cache and is never modified, and an - * in-progress merge keeps its unmerged entries; a shadow repository keeps the - * refreshed index for its next snapshot. + * not ignored ones, as a tree object through a private index seeded from the + * repository's index. That index, the work tree, and every ref stay unchanged; + * an in-progress merge keeps its unmerged entries. * @param git - command runner. * @param workspace - addressed repository. * @param signal - cancellation. @@ -157,14 +124,11 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign const scratch = await mkdtemp(join(tmpdir(), 'dsh-workspace-changes-')) try { const index = join(scratch, 'index') - const persisted = join(workspace.gitDir, 'index') - // A missing persisted index (a fresh shadow repository) starts from scratch. - await copyFile(persisted, index).then(() => undefined, () => undefined) - const env = { ...workspace.env, GIT_INDEX_FILE: index } + // A repository without an index yet (fresh `git init`) starts from scratch. + await copyFile(join(workspace.gitDir, 'index'), index).then(() => undefined, () => undefined) + const env = { GIT_INDEX_FILE: index } ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`) - const tree = ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim() - if (workspace.kind === 'shadow') await rename(index, persisted) - return tree + return ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim() } finally { await rm(scratch, { recursive: true, force: true }) } @@ -177,16 +141,14 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign * @param before - turn-start tree id. * @param after - turn-end tree id. * @param signal - cancellation. - * @returns changed files relative to the work-tree root. + * @returns changed files relative to the repository root. * @throws when git fails or the output exceeded the cap. */ export async function diffTrees( git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal, ): Promise { if (before === after) return [] - const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { - cwd: workspace.root, env: workspace.env, signal, - }), 'git diff-tree') + const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { cwd: workspace.root, signal }), 'git diff-tree') if (result.truncated) throw new Error('git diff-tree output exceeded the configured cap') return parseNumstat(result.stdout) } @@ -197,7 +159,7 @@ export async function diffTrees( * counts as covered by snapshots. * @param git - command runner. * @param workspace - addressed repository. - * @param paths - slash-separated paths relative to the work-tree root. + * @param paths - slash-separated paths relative to the repository root. * @param signal - cancellation. * @returns the ignored members of `paths`. */ @@ -205,9 +167,7 @@ export async function ignoredPaths( git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal, ): Promise> { if (paths.length === 0) return new Set() - const result = await git.run(['check-ignore', '-z', '--stdin'], { - cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal, - }) + const result = await git.run(['check-ignore', '-z', '--stdin'], { cwd: workspace.root, stdin: `${paths.join('\0')}\0`, signal }) if (result.exitCode === 1) return new Set() return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== '')) } diff --git a/packages/fs/workspace-changes/src/index.ts b/packages/fs/workspace-changes/src/index.ts index a0ae09a993..e6231f0fcf 100644 --- a/packages/fs/workspace-changes/src/index.ts +++ b/packages/fs/workspace-changes/src/index.ts @@ -2,13 +2,12 @@ * Records the files each top-level turn changed as a durable `workspace/changes` * Session event, derived from git working-tree snapshots taken at turn start * and turn end plus the hunks file tools persist for paths git does not cover. + * Only a working directory inside a git repository is recorded. */ import { realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type {} from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-subprocess' @@ -25,33 +24,21 @@ export const name = 'workspace-changes' /** Services used to run git and observe turns. */ export const inject = ['subprocess'] -/** Directories excluded from shadow repositories, where no `.gitignore` exists. */ -export const DEFAULT_SHADOW_EXCLUDES: readonly string[] = [ - '.git/', '.dsh/', '.DS_Store', 'node_modules/', '.venv/', 'venv/', '__pycache__/', '.pytest_cache/', '.mypy_cache/', - 'dist/', 'build/', 'out/', 'target/', '.cache/', '.next/', '.nuxt/', '.turbo/', 'coverage/', -] - -/** Snapshot bounds and shadow repository placement. Invalid values fail plugin load. */ +/** Snapshot bounds. Invalid values fail plugin load. */ export interface Config { - /** Harness home that holds shadow repositories under `workspace-changes/`; defaults to `$DSH_HOME`, then `~/.dsh`. */ - dshHome?: string /** Milliseconds one git command may run before the turn's record is abandoned. */ timeoutMs: number /** Bytes of git output retained per command; a larger diff listing abandons the record. */ outputMaxBytes: number /** Maximum files carried by one event; `total` still reports the complete count. */ maxFiles: number - /** `info/exclude` patterns for shadow repositories. */ - shadowExcludes: string[] } /** Schemastery validation for {@link Config}. */ export const Config: z = z.object({ - dshHome: z.string(), timeoutMs: z.number().default(30_000), outputMaxBytes: z.number().default(8 * 1024 * 1024), maxFiles: z.number().default(500), - shadowExcludes: z.array(z.string()).default([...DEFAULT_SHADOW_EXCLUDES]), }) function eligible(session: Session): string | undefined { @@ -84,8 +71,8 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise | undefined @@ -118,7 +104,7 @@ export function apply(ctx: Context, config: Config): void { let recorder = recorders.get(session) if (recorder === undefined) { recorder = new TurnRecorder(session, cwd, { - git: gitRunner(), shadow, home, temporaryRoots: roots, maxFiles: config.maxFiles, + git: gitRunner(), home, temporaryRoots: roots, maxFiles: config.maxFiles, warn: (message) => { ctx.logger.warn(message) }, }) recorders.set(session, recorder) diff --git a/packages/fs/workspace-changes/src/recorder.ts b/packages/fs/workspace-changes/src/recorder.ts index 1cd90daab7..46b4facc0f 100644 --- a/packages/fs/workspace-changes/src/recorder.ts +++ b/packages/fs/workspace-changes/src/recorder.ts @@ -3,7 +3,7 @@ import { realpath } from 'node:fs/promises' import { relative } from 'node:path' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace, type ShadowRepositoryOptions } from './git.ts' +import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts' import { fileDiffsOf, hunkLineCounts } from './numstat.ts' import { absolutePathOf, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, toPosix } from './paths.ts' import type { WorkspaceChangedFile } from './types.ts' @@ -12,7 +12,6 @@ import type { WorkspaceChangedFile } from './types.ts' export interface RecorderEnvironment { /** Resolves to the runner, or null when git is unavailable and no turn records anything. */ git: Promise - shadow: ShadowRepositoryOptions /** Canonical absolute home directory abbreviated as `~` in display paths. */ home: string /** Temporary roots whose files never enter a summary. */ @@ -38,7 +37,8 @@ async function canonicalPath(path: string): Promise { /** * Serializes one Session's git work: the turn-start snapshot, the turn-end * snapshot with its diff, and the appended `workspace/changes` event. Tool - * execution waits for pending work so a snapshot never races a mutation. + * execution waits for pending work so a snapshot never races a mutation. A + * working directory outside any repository records nothing. */ export class TurnRecorder { private chain: Promise = Promise.resolve() @@ -68,7 +68,8 @@ export class TurnRecorder { if (git === null) return // git reports symlink-resolved paths; every comparison uses that form. const cwd = await realpath(this.cwd) - const workspace = await locateGitWorkspace(git, cwd, this.env.shadow, signal) + const workspace = await locateGitWorkspace(git, cwd, signal) + if (workspace === null) return const tree = await snapshotTree(git, workspace, signal) this.baseline = { git, workspace, tree, cwd } }) diff --git a/packages/fs/workspace-changes/tests/git.spec.ts b/packages/fs/workspace-changes/tests/git.spec.ts index 4cf8b963a7..7a391d729e 100644 --- a/packages/fs/workspace-changes/tests/git.spec.ts +++ b/packages/fs/workspace-changes/tests/git.spec.ts @@ -1,5 +1,5 @@ /** Git command bounds, snapshot recovery, and diff failure reporting. */ -import { mkdir, writeFile } from 'node:fs/promises' +import { realpath, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -56,9 +56,9 @@ describe('snapshots and diffs', () => { expect(() => git(cwd, 'merge', 'side')).toThrow() expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt') const { git: runnerGit } = await runner() - const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal) - expect(workspace.kind).toBe('repository') - const tree = await snapshotTree(runnerGit, workspace, signal) + const workspace = await locateGitWorkspace(runnerGit, cwd, signal) + expect(workspace?.root).toBe(await realpath(cwd)) + const tree = await snapshotTree(runnerGit, workspace!, signal) expect(tree).toMatch(/^[0-9a-f]{40,64}$/) expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt') }) @@ -66,12 +66,13 @@ describe('snapshots and diffs', () => { it('fails loudly when the addressed repository cannot be written or diffed', async () => { const cwd = await scratchDir('dsh-git-broken-', cleanups) const { git: runnerGit } = await runner() - const broken = { kind: 'shadow' as const, root: cwd, gitDir: join(cwd, 'missing'), env: { GIT_DIR: join(cwd, 'missing'), GIT_WORK_TREE: cwd } } + expect(await locateGitWorkspace(runnerGit, cwd, signal)).toBeNull() + const broken = { root: cwd, gitDir: join(cwd, 'missing') } await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in') await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed') expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set()) git(cwd, 'init', '-q') - const workspace = await locateGitWorkspace(runnerGit, cwd, { home: join(cwd, 'unused'), excludes: [] }, signal) + const workspace = (await locateGitWorkspace(runnerGit, cwd, signal))! await expect(diffTrees(runnerGit, workspace, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow('git diff-tree failed') await writeFile(join(cwd, 'many.txt'), Array.from({ length: 50 }, (_, index) => `line ${index}`).join('\n')) const before = await snapshotTree(runnerGit, workspace, signal) @@ -83,17 +84,6 @@ describe('snapshots and diffs', () => { expect((await diffTrees(runnerGit, workspace, before, after, signal)).length).toBe(21) }) - it('refuses a shadow home whose git directory cannot be initialized', async () => { - const cwd = await scratchDir('dsh-git-shadow-broken-', cleanups) - const home = join(cwd, 'home') - const { git: runnerGit } = await runner() - const probe = await locateGitWorkspace(runnerGit, cwd, { home, excludes: ['x/'] }, signal) - expect(probe.kind).toBe('shadow') - await mkdir(join(probe.gitDir, 'objects', 'broken'), { recursive: true }) - await writeFile(join(probe.gitDir, 'HEAD'), 'not a ref') - await writeFile(join(probe.gitDir, 'config'), '[core]\n\tbare = maybe\n') - await expect(locateGitWorkspace(runnerGit, cwd, { home, excludes: [] }, signal)).rejects.toThrow('git init of shadow repository') - }) }) describe('TurnRecorder', () => { @@ -104,7 +94,7 @@ describe('TurnRecorder', () => { const warnings: string[] = [] let release!: (runner: GitRunner | null) => void const gate = new Promise((resolve) => { release = resolve }) - const env = { git: gate, shadow: { home: join(cwd, 'home'), excludes: [] }, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } } + const env = { git: gate, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } } const disposed = new TurnRecorder(session, cwd, env) disposed.start(1) await new Promise(resolve => setTimeout(resolve, 5)) diff --git a/packages/fs/workspace-changes/tests/loader-composition.spec.ts b/packages/fs/workspace-changes/tests/loader-composition.spec.ts index 1d3f87f9a3..0a086da6fb 100644 --- a/packages/fs/workspace-changes/tests/loader-composition.spec.ts +++ b/packages/fs/workspace-changes/tests/loader-composition.spec.ts @@ -34,8 +34,6 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-subprocess-local'", "- name: '@deepseek-ai/dsh-workspace-changes'", - ' config:', - ` dshHome: ${JSON.stringify(join(root, 'home'))}`, '', ].join('\n')) context = new Context() diff --git a/packages/fs/workspace-changes/tests/plugin.spec.ts b/packages/fs/workspace-changes/tests/plugin.spec.ts index acd3065da3..1aa9da3345 100644 --- a/packages/fs/workspace-changes/tests/plugin.spec.ts +++ b/packages/fs/workspace-changes/tests/plugin.spec.ts @@ -22,9 +22,8 @@ async function boot(config: Partial = {}) { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(LocalSubprocessRuntime) - const dshHome = config.dshHome ?? await scratchDir('dsh-workspace-changes-home-', cleanups) - const fiber = await ctx.plugin(WorkspaceChanges, { ...config, dshHome } as WorkspaceChanges.Config) - return { ctx, fiber, dshHome } + const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config) + return { ctx, fiber } } async function repository(): Promise { @@ -170,10 +169,9 @@ describe('workspace-changes in a repository', () => { }) it('warns and skips the turn when its git work fails', async () => { - const cwd = await scratchDir('dsh-workspace-changes-warn-', cleanups) - const blocker = join(cwd, 'blocker') - await writeFile(blocker, 'not a directory') - const { ctx } = await boot({ dshHome: blocker }) + const cwd = await repository() + const { ctx } = await boot() + vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(join(cwd, 'missing-git')) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const session = ctx.sessions.create(SessionId('warn'), { meta: { cwd } }) startTurn(session, 1) @@ -185,7 +183,7 @@ describe('workspace-changes in a repository', () => { expect(changes(session)).toEqual([]) const own = warn.mock.calls.map(call => String(call[0])).filter(message => message.startsWith('workspace-changes:')) expect(own).toHaveLength(1) - expect(own[0]).toContain('ENOTDIR') + expect(own[0]).toContain('missing-git') }) it('rejects non-positive bounds at load', async () => { @@ -198,43 +196,27 @@ describe('workspace-changes in a repository', () => { }) describe('workspace-changes without a repository', () => { - it('snapshots into a shadow repository under the Harness home and excludes that home', async () => { + it('records nothing for a working directory outside any git repository', async () => { const cwd = await scratchDir('dsh-workspace-changes-plain-', cleanups) - const dshHome = join(cwd, '.dsh') await writeFile(join(cwd, 'existing.txt'), 'before\n') - const { ctx } = await boot({ dshHome }) + const { ctx } = await boot() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } }) startTurn(session, 1) await settle(ctx, session) await writeFile(join(cwd, 'existing.txt'), 'after\nmore\n') - await mkdir(join(cwd, 'node_modules'), { recursive: true }) - await writeFile(join(cwd, 'node_modules', 'dep.js'), 'module\n') - await writeFile(join(dshHome, 'settings.yaml'), 'changed: true\n') - toolCall(session, 1, 'write', { file_path: 'node_modules/dep.js' }, { - meta: { diffs: [{ path: 'node_modules/dep.js', oldText: null, newText: 'module\n' }] }, + toolCall(session, 1, 'write', { file_path: 'existing.txt' }, { + meta: { diffs: [{ path: 'existing.txt', oldText: 'before', newText: 'after\nmore' }] }, }) endTurn(session, 1) await settle(ctx, session) - const [first] = changes(session) - expect(first!.files).toEqual([ - { path: 'existing.txt', display: 'existing.txt', added: 2, deleted: 1 }, - { path: 'node_modules/dep.js', display: 'node_modules/dep.js', added: 1, deleted: 0 }, - ]) - const shadows = join(dshHome, 'workspace-changes') - expect((await stat(shadows)).isDirectory()).toBe(true) + expect(changes(session)).toEqual([]) + expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([]) await expect(stat(join(cwd, '.git'))).rejects.toThrow() - - startTurn(session, 2) - await settle(ctx, session) - await writeFile(join(cwd, 'second.txt'), 's\n') - toolCall(session, 2, 'bash', { command: 'x' }) - endTurn(session, 2) - await settle(ctx, session) - expect(changes(session).at(-1)!.files.map(file => file.display)).toEqual(['second.txt']) }) it('drops a disposed session’s recorder and starts afresh on its next turn', async () => { - const cwd = await scratchDir('dsh-workspace-changes-disposed-', cleanups) + const cwd = await repository() const { ctx, fiber } = await boot() const session = ctx.sessions.create(SessionId('disposed'), { meta: { cwd } }) startTurn(session, 1) @@ -264,7 +246,7 @@ describe('workspace-changes without a repository', () => { }) it('ignores subagent sessions and sessions without a working directory', async () => { - const cwd = await scratchDir('dsh-workspace-changes-skip-', cleanups) + const cwd = await repository() const { ctx } = await boot() const sessions = [ ctx.sessions.create(SessionId('child'), { meta: { cwd, delegationDepth: 1 } }), diff --git a/packages/fs/workspace-changes/tsconfig.json b/packages/fs/workspace-changes/tsconfig.json index b0d5795f1d..a349b93708 100644 --- a/packages/fs/workspace-changes/tsconfig.json +++ b/packages/fs/workspace-changes/tsconfig.json @@ -25,9 +25,6 @@ }, { "path": "../../subprocess/subprocess" - }, - { - "path": "../../util/home-paths" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b176b19ea5..ababbf38af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6288,9 +6288,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/snapshots/web/changed-files-turn/session.v3.jsonl b/snapshots/web/changed-files-turn/session.v3.jsonl new file mode 100644 index 0000000000..3f24f87b21 --- /dev/null +++ b/snapshots/web/changed-files-turn/session.v3.jsonl @@ -0,0 +1,43 @@ +{"type":"session","version":3,"id":"{{session:1}}","createdAt":1789118422326,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"不用先查看目录,直接做三件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,然后用 bash 在 notes.txt 末尾追加一行 done。"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"不用先查看目录,直接做三件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,然后用 bash 在 notes.txt 末尾追加一行 done。"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"session/title","data":{"title":"不用先查看目录,直接做三件","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants three things without checking the directory first. Let me just do them. But the edit tool requires reading the file first per fs-observation-policy. The user said \"不用先查看目录\" (no need to check directory first) — that's about listing directory, not about reading file. The edit tool requires exact match of old_string. Hmm, I need to know the content of intro.md to edit. I could use edit with old_string \"示例项目\" — but the tool says read first unless just created/edited. Let me try reading intro.md quickly, that's fine.\n\nActually the instruction says read first (default fs-observation-policy requires it). Let me read intro.md and notes.txt maybe. For append via bash, I don't need to read.\n\nLet me do: read intro.md, and write src/util.ts, and bash append. These are independent-ish; reading is needed before edit though. Let me run read + write + bash in one block, then edit after.\n\nActually write tool for new file is fine. bash append to notes.txt — \">>\" append. Let's go."},{"type":"tool-call","id":"call_00_djgvWjyc30RFftv5UPDE7742","name":"read","arguments":"{\"file_path\": \"intro.md\"}"},{"type":"tool-call","id":"call_01_swrvOQZh7YooRuJ9ne7D3701","name":"write","arguments":"{\"file_path\": \"src/util.ts\", \"content\": \"/**\\n * 两数相加\\n */\\nexport function add(a: number, b: number): number {\\n return a + b;\\n}\\n\"}"},{"type":"tool-call","id":"call_02_TJtdauSVDCQAKF3eDQ6N4042","name":"bash","arguments":"{\"command\": \"printf 'done\\\\n' >> notes.txt && tail -n 3 notes.txt\", \"description\": \"Append done line to notes.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":7325,"outputTokens":407,"totalTokens":8756,"cacheReadTokens":1024,"reasoningTokens":231},"stream":[{"type":"chunk","time":1789118424873,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789118424873,"index":0,"dt":[213,1,0,1,0,0,0,0,1,0,0,0,23,0,1,0,27,0,0,38,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,18,0,0,0,0,0,0,0,4,71,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,2,4,0,0,6,0,6,3,0,13,1,0,0,0,0,2,3,6,2,18,0,0,0,0,0,0,0,0,2,2,6,15,0,3,8,6,0,0,0,0,8,11,0,19,3,0,13,2,0,31,0,0,0,0,6,6,2,19,0,0,0,0,2,8,4,0,0,0,12,3,0,0,9,2,0,13,2,11,0,13,4,0,0,10,3,0,0,9,4,1,0,0,17,0,0,0,0,0,6,13,4,0,0,0,8,5,0,0,9,3,0,0,9,3,12,3,0,9,4,0,0,0,81,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0,13,0,0,11,2,0,7,3,1,0,9,2,0,11,3,10,2],"texts":["The"," user"," wants"," three"," things"," without"," checking"," the"," directory"," first","."," Let"," me"," just"," do"," them","."," But"," the"," edit"," tool"," requires"," reading"," the"," file"," first"," per"," fs","-","observation","-p","olicy","."," The"," user"," said"," \"","不用","先","查看","目录","\""," (","no"," need"," to"," check"," directory"," first",")"," —"," that","'s"," about"," listing"," directory",","," not"," about"," reading"," file","."," The"," edit"," tool"," requires"," exact"," match"," of"," old","_string","."," H","mm",","," I"," need"," to"," know"," the"," content"," of"," intro",".md"," to"," edit","."," I"," could"," use"," edit"," with"," old","_string"," \"","示例","项目","\""," —"," but"," the"," tool"," says"," read"," first"," unless"," just"," created","/","edited","."," Let"," me"," try"," reading"," intro",".md"," quickly",","," that","'s"," fine",".\n\n","Actually"," the"," instruction"," says"," read"," first"," (","default"," fs","-","observation","-p","olicy"," requires"," it",")."," Let"," me"," read"," intro",".md"," and"," notes",".txt"," maybe","."," For"," append"," via"," bash",","," I"," don","'t"," need"," to"," read",".\n\n","Let"," me"," do",":"," read"," intro",".md",","," and"," write"," src","/util",".ts",","," and"," bash"," append","."," These"," are"," independent","-ish",";"," reading"," is"," needed"," before"," edit"," though","."," Let"," me"," run"," read"," +"," write"," +"," bash"," in"," one"," block",","," then"," edit"," after",".\n\n","Actually"," write"," tool"," for"," new"," file"," is"," fine","."," bash"," append"," to"," notes",".txt"," —"," \"",">>","\""," append","."," Let","'s"," go","."]},{"type":"chunk","time":1789118425905,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789118425905,"index":1,"dt":[13,5,1,0,0,0,7,2,0,13,0],"id":"call_00_djgvWjyc30RFftv5UPDE7742","name":"read","args":["","{","\"","file","_path","\"",": ","\"","intro",".md","\"","}"]},{"type":"chunk","time":1789118425971,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789118425971,"index":2,"dt":[17,0,0,0,0,2,12,3,0,0,7,12,3,0,0,0,11,2,13,2,0,16,2,0,7,5,0,0,0,0,9,6,0,0,0,0,5,5,0,0,0,0,11,2,1,0,9],"id":"call_01_swrvOQZh7YooRuJ9ne7D3701","name":"write","args":["","{","\"","file","_path","\"",": ","\"","src","/util",".ts","\"",", ","\"","content","\"",": ","\"","/**\\n"," *"," ","两","数","相加","\\n"," */\\n","export"," function"," add","(a",":"," number",","," b",":"," number","):"," number"," {\\n"," "," return"," a"," +"," b",";\\n","}\\n","\"","}"]},{"type":"chunk","time":1789118426146,"chunk":{"type":"block-start","index":3,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789118426146,"index":3,"dt":[13,2,0,11,3,1,0,10,5,0,0,0,0,17,0,0,0,0,0,5,4,0,1,10,2,11,3,0,0,79,1,0,0,0,0,1,0,1],"id":"call_02_TJtdauSVDCQAKF3eDQ6N4042","name":"bash","args":["","{","\"","command","\"",": ","\"","printf"," '","done","\\\\n","'"," >>"," notes",".txt"," &&"," tail"," -","n"," ","3"," notes",".txt","\"",", ","\"","description","\"",": ","\"","App","end"," done"," line"," to"," notes",".txt","\"","}"]},{"type":"chunk","time":1789118426338,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants three things without checking the directory first. Let me just do them. But the edit tool requires reading the file first per fs-observation-policy. The user said \"不用先查看目录\" (no need to check directory first) — that's about listing directory, not about reading file. The edit tool requires exact match of old_string. Hmm, I need to know the content of intro.md to edit. I could use edit with old_string \"示例项目\" — but the tool says read first unless just created/edited. Let me try reading intro.md quickly, that's fine.\n\nActually the instruction says read first (default fs-observation-policy requires it). Let me read intro.md and notes.txt maybe. For append via bash, I don't need to read.\n\nLet me do: read intro.md, and write src/util.ts, and bash append. These are independent-ish; reading is needed before edit though. Let me run read + write + bash in one block, then edit after.\n\nActually write tool for new file is fine. bash append to notes.txt — \">>\" append. Let's go."}}},{"type":"chunk","time":1789118426339,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_djgvWjyc30RFftv5UPDE7742","name":"read","arguments":"{\"file_path\": \"intro.md\"}"}}},{"type":"chunk","time":1789118426339,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_swrvOQZh7YooRuJ9ne7D3701","name":"write","arguments":"{\"file_path\": \"src/util.ts\", \"content\": \"/**\\n * 两数相加\\n */\\nexport function add(a: number, b: number): number {\\n return a + b;\\n}\\n\"}"}}},{"type":"chunk","time":1789118426339,"chunk":{"type":"block-end","index":3,"block":{"type":"tool-call","id":"call_02_TJtdauSVDCQAKF3eDQ6N4042","name":"bash","arguments":"{\"command\": \"printf 'done\\\\n' >> notes.txt && tail -n 3 notes.txt\", \"description\": \"Append done line to notes.txt\"}"}}},{"type":"chunk","time":1789118426339,"chunk":{"type":"usage","usage":{"inputTokens":7325,"outputTokens":407,"totalTokens":8756,"cacheReadTokens":1024,"reasoningTokens":231}}},{"type":"chunk","time":1789118426339,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_djgvWjyc30RFftv5UPDE7742","name":"read","arguments":"{\"file_path\": \"intro.md\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_djgvWjyc30RFftv5UPDE7742"},"content":[{"type":"tool-result","toolCallId":"call_00_djgvWjyc30RFftv5UPDE7742","content":[{"type":"text","text":"{{cwd}}/intro.md\nfile\n\n1: # 示例项目\n2: \n3: 一个用于演示的仓库。\n\n(End of file - total 3 lines)\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"path":"{{cwd}}/intro.md","offset":1,"lines":[{"number":1,"text":"# 示例项目"},{"number":2,"text":""},{"number":3,"text":"一个用于演示的仓库。"}],"totalLines":3,"lang":"md"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_01_swrvOQZh7YooRuJ9ne7D3701","name":"write","arguments":"{\"file_path\": \"src/util.ts\", \"content\": \"/**\\n * 两数相加\\n */\\nexport function add(a: number, b: number): number {\\n return a + b;\\n}\\n\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_swrvOQZh7YooRuJ9ne7D3701"},"content":[{"type":"tool-result","toolCallId":"call_01_swrvOQZh7YooRuJ9ne7D3701","content":[{"type":"text","text":"{{cwd}}/src/util.ts\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:6}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_02_TJtdauSVDCQAKF3eDQ6N4042","name":"bash","arguments":"{\"command\": \"printf 'done\\\\n' >> notes.txt && tail -n 3 notes.txt\", \"description\": \"Append done line to notes.txt\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_02_TJtdauSVDCQAKF3eDQ6N4042"},"content":[{"type":"tool-result","toolCallId":"call_02_TJtdauSVDCQAKF3eDQ6N4042","content":[{"type":"text","text":"start\ndone\n"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_gbsHgvxNmErYXtFrLOKj8426","name":"edit","arguments":"{\"file_path\": \"intro.md\", \"old_string\": \"# 示例项目\", \"new_string\": \"# 项目说明\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":248,"outputTokens":79,"totalTokens":9031,"cacheReadTokens":8704,"reasoningTokens":0},"stream":[{"type":"chunk","time":1789118428101,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789118428102,"index":0,"dt":[8,3,0,0,14,3,0,0,8,2,12,2,0,1,8,29,0,1,0,0,0,0,14,3,0,0,0,8,5,0,0,12,2,0,10],"id":"call_00_ET_gbsHgvxNmErYXtFrLOKj8426","name":"edit","args":["","{","\"","file","_path","\"",": ","\"","intro",".md","\"",", ","\"","old","_string","\"",": ","\"","#"," ","示例","项目","\"",", ","\"","new","_string","\"",": ","\"","#"," ","项目","说明","\"","}"]},{"type":"chunk","time":1789118428267,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_gbsHgvxNmErYXtFrLOKj8426","name":"edit","arguments":"{\"file_path\": \"intro.md\", \"old_string\": \"# 示例项目\", \"new_string\": \"# 项目说明\"}"}}},{"type":"chunk","time":1789118428267,"chunk":{"type":"usage","usage":{"inputTokens":248,"outputTokens":79,"totalTokens":9031,"cacheReadTokens":8704,"reasoningTokens":0}}},{"type":"chunk","time":1789118428267,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_ET_gbsHgvxNmErYXtFrLOKj8426","name":"edit","arguments":"{\"file_path\": \"intro.md\", \"old_string\": \"# 示例项目\", \"new_string\": \"# 项目说明\"}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_ET_gbsHgvxNmErYXtFrLOKj8426"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_gbsHgvxNmErYXtFrLOKj8426","content":[{"type":"text","text":"The file {{cwd}}/intro.md has been updated successfully."}],"isError":false}],"role":"user","id":"{{message:9}}"},"meta":{"diffs":[{"path":"intro.md","oldText":"# 示例项目\n\n一个用于演示的仓库。","newText":"# 项目说明\n\n一个用于演示的仓库。"}]}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Verify the three results quickly."},{"type":"tool-call","id":"call_00_6j7tvJ13TezxPw3AfFun8396","name":"bash","arguments":"{\"command\": \"head -1 intro.md; echo '---'; cat src/util.ts; echo '---'; tail -1 notes.txt\", \"description\": \"Verify all three changes\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":138,"outputTokens":85,"totalTokens":9183,"cacheReadTokens":8960,"reasoningTokens":6},"stream":[{"type":"chunk","time":1789118429898,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789118429898,"index":0,"dt":[105,1,0,0,1],"texts":["Verify"," the"," three"," results"," quickly","."]},{"type":"chunk","time":1789118430021,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789118430021,"index":1,"dt":[16,0,0,0,12,3,1,0,13,0,28,0,0,0,0,1,1,4,0,0,0,0,13,3,0,0,10,2,15,3,0,16,2,0,14,2,0,0,14,16,2,0,17],"id":"call_00_6j7tvJ13TezxPw3AfFun8396","name":"bash","args":["","{","\"","command","\"",": ","\"","head"," -","1"," intro",".md",";"," echo"," '","---","';"," cat"," src","/util",".ts",";"," echo"," '","---","';"," tail"," -","1"," notes",".txt","\"",", ","\"","description","\"",": ","\"","Verify"," all"," three"," changes","\"","}"]},{"type":"chunk","time":1789118430257,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Verify the three results quickly."}}},{"type":"chunk","time":1789118430257,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6j7tvJ13TezxPw3AfFun8396","name":"bash","arguments":"{\"command\": \"head -1 intro.md; echo '---'; cat src/util.ts; echo '---'; tail -1 notes.txt\", \"description\": \"Verify all three changes\"}"}}},{"type":"chunk","time":1789118430257,"chunk":{"type":"usage","usage":{"inputTokens":138,"outputTokens":85,"totalTokens":9183,"cacheReadTokens":8960,"reasoningTokens":6}}},{"type":"chunk","time":1789118430257,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_00_6j7tvJ13TezxPw3AfFun8396","name":"bash","arguments":"{\"command\": \"head -1 intro.md; echo '---'; cat src/util.ts; echo '---'; tail -1 notes.txt\", \"description\": \"Verify all three changes\"}"}} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_6j7tvJ13TezxPw3AfFun8396"},"content":[{"type":"tool-result","toolCallId":"call_00_6j7tvJ13TezxPw3AfFun8396","content":[{"type":"text","text":"# 项目说明\n---\n/**\n * 两数相加\n */\nexport function add(a: number, b: number): number {\n return a + b;\n}\n---\ndone\n"}],"isError":false}],"role":"user","id":"{{message:11}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"step/start","data":{"turn":1,"step":4}} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Present the files as deliverables."},{"type":"text","text":"三件事都已完成并验证:\n\n1. **`intro.md`** — 标题已从 `# 示例项目` 改为 `# 项目说明`(edit 工具需先读取文件以精确匹配,因此只读了这一个文件,未列目录)。\n2. **`src/util.ts`**(新建)— 导出带类型的 `add(a: number, b: number): number` 函数。\n3. **`notes.txt`** — 用 bash 以 `>>` 在末尾追加了 `done`(当前文件内容为 `start` / `done` 两行)。"},{"type":"tool-call","id":"call_00_hw9v5WruKnMjnerrC7A65759","name":"present","arguments":"{\"files\": [{\"path\": \"intro.md\", \"description\": \"标题已改为「项目说明」\"}, {\"path\": \"src/util.ts\", \"description\": \"新建,导出 add 函数\"}, {\"path\": \"notes.txt\", \"description\": \"末尾已追加 done\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":144,"outputTokens":226,"totalTokens":9458,"cacheReadTokens":9088,"reasoningTokens":6},"stream":[{"type":"chunk","time":1789118432969,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789118432970,"index":0,"dt":[0,0,0,0,0],"texts":["Present"," the"," files"," as"," deliverables","."]},{"type":"chunk","time":1789118432970,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789118432970,"index":1,"dt":[0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,88,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["三","件事","都","已完成","并","验证",":\n\n","1","."," **","`","intro",".md","`","**"," —"," ","标题","已","从"," `","#"," ","示例","项目","`"," ","改为"," `","#"," ","项目","说明","`","(","edit"," ","工具","需","先","读取","文件","以","精确","匹配",",","因此","只","读了","这一个","文件",",","未","列","目录",")。\n","2","."," **","`","src","/util",".ts","`","**","(","新建",")","—"," ","导出","带","类型的"," `","add","(a",":"," number",","," b",":"," number","):"," number","`"," ","函数","。\n","3","."," **","`","notes",".txt","`","**"," —"," ","用"," bash"," ","以"," `",">>","`"," ","在","末尾","追加","了"," `","done","`","(","当前","文件","内容","为"," `","start","`"," /"," `","done","`"," ","两","行",")。"]},{"type":"chunk","time":1789118433066,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789118433066,"index":2,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,84,0],"id":"call_00_hw9v5WruKnMjnerrC7A65759","name":"present","args":["","{","\"","files","\"",": ","[","{\"","path","\":"," \"","intro",".md","\","," \"","description","\":"," \"","标题","已","改为","「","项目","说明","」","\"},"," {\"","path","\":"," \"","src","/util",".ts","\","," \"","description","\":"," \"","新建",",","导出"," add"," ","函数","\"},"," {\"","path","\":"," \"","notes",".txt","\","," \"","description","\":"," \"","末尾","已","追加"," done","\"","}]","}"]},{"type":"chunk","time":1789118433152,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Present the files as deliverables."}}},{"type":"chunk","time":1789118433152,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"三件事都已完成并验证:\n\n1. **`intro.md`** — 标题已从 `# 示例项目` 改为 `# 项目说明`(edit 工具需先读取文件以精确匹配,因此只读了这一个文件,未列目录)。\n2. **`src/util.ts`**(新建)— 导出带类型的 `add(a: number, b: number): number` 函数。\n3. **`notes.txt`** — 用 bash 以 `>>` 在末尾追加了 `done`(当前文件内容为 `start` / `done` 两行)。"}}},{"type":"chunk","time":1789118433152,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_hw9v5WruKnMjnerrC7A65759","name":"present","arguments":"{\"files\": [{\"path\": \"intro.md\", \"description\": \"标题已改为「项目说明」\"}, {\"path\": \"src/util.ts\", \"description\": \"新建,导出 add 函数\"}, {\"path\": \"notes.txt\", \"description\": \"末尾已追加 done\"}]}"}}},{"type":"chunk","time":1789118433152,"chunk":{"type":"usage","usage":{"inputTokens":144,"outputTokens":226,"totalTokens":9458,"cacheReadTokens":9088,"reasoningTokens":6}}},{"type":"chunk","time":1789118433152,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":4,"callId":"call_00_hw9v5WruKnMjnerrC7A65759","name":"present","arguments":"{\"files\": [{\"path\": \"intro.md\", \"description\": \"标题已改为「项目说明」\"}, {\"path\": \"src/util.ts\", \"description\": \"新建,导出 add 函数\"}, {\"path\": \"notes.txt\", \"description\": \"末尾已追加 done\"}]}"}} +{"type":"deliverables/presented","data":{"turn":1,"callId":"call_00_hw9v5WruKnMjnerrC7A65759","files":[{"path":"intro.md","description":"标题已改为「项目说明」"},{"path":"src/util.ts","description":"新建,导出 add 函数"},{"path":"notes.txt","description":"末尾已追加 done"}]}} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_00_hw9v5WruKnMjnerrC7A65759"},"content":[{"type":"tool-result","toolCallId":"call_00_hw9v5WruKnMjnerrC7A65759","content":[{"type":"text","text":"Presented intro.md\nPresented src/util.ts\nPresented notes.txt"}],"isError":false}],"role":"user","id":"{{message:13}}"}},"sourceEventSeqs":[33],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":4}} +{"type":"step/start","data":{"turn":1,"step":5}} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"改动文件:`intro.md`、`src/util.ts`、`notes.txt`。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":141,"outputTokens":20,"totalTokens":9505,"cacheReadTokens":9344,"reasoningTokens":0},"stream":[{"type":"chunk","time":1789118434916,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789118434916,"index":0,"dt":[1,1,0,1,0,0,0,0,1,0,0,0,1,0,1,1,0,0],"texts":["改动","文件",":","`","intro",".md","`","、","`","src","/util",".ts","`","、","`","notes",".txt","`","。"]},{"type":"chunk","time":1789118434923,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"改动文件:`intro.md`、`src/util.ts`、`notes.txt`。"}}},{"type":"chunk","time":1789118434924,"chunk":{"type":"usage","usage":{"inputTokens":141,"outputTokens":20,"totalTokens":9505,"cacheReadTokens":9344,"reasoningTokens":0}}},{"type":"chunk","time":1789118434924,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":5}} +{"type":"workspace/changes","data":{"turn":1,"files":[{"path":"intro.md","display":"intro.md","added":1,"deleted":1},{"path":"notes.txt","display":"notes.txt","added":1,"deleted":0},{"path":"src/util.ts","display":"src/util.ts","added":6,"deleted":0}],"total":3,"snapshot":{"before":"13806d734363b62201c8a7b38aeeefb446747dcb","after":"19c70666ca7d4c5151f7100d69cc25339d5d9af2"}}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/changed-files-turn/snapshot.yml b/snapshots/web/changed-files-turn/snapshot.yml new file mode 100644 index 0000000000..c8ddf33785 --- /dev/null +++ b/snapshots/web/changed-files-turn/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: changed-files-turn +profile: web +composition: web-default +recording: live +header: + class: web-default +workspace: + final: true diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md new file mode 100644 index 0000000000..f77b7cf65f --- /dev/null +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -0,0 +1,133 @@ +- button "系统提示词": + - img + - img + - text: 系统提示词 +- text: 不用先查看目录,直接做三件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,然后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}} +- button "复制": + - img +- button "6 次工具调用 · 1 条消息" [expanded]: + - text: 6 次工具调用 · 1 条消息 + - img +- button "上下文注入 @deepseek-ai/dsh-system-prompt": + - img + - img + - text: 上下文注入 @deepseek-ai/dsh-system-prompt +- button "思考 The user wants three things without checking the directory first. Let me just do them. But the edit tool requires reading the file first per fs-observation-policy. The user said \"不用先查看目录\" (no need to check directory first) — that's about listing directory, not about reading file. The edit tool requires exact match of old_string. Hmm, I need to know the content of intro.md to edit. I could use edit with old_string \"示例项目\" — but the tool says read first unless just created/edited. Let me try reading intro.md quickly, that's fine.": + - img + - img + - text: 思考 The user wants three things without checking the directory first. Let me just do them. But the edit tool requires reading the file first per fs-observation-policy. The user said "不用先查看目录" (no need to check directory first) — that's about listing directory, not about reading file. The edit tool requires exact match of old_string. Hmm, I need to know the content of intro.md to edit. I could use edit with old_string "示例项目" — but the tool says read first unless just created/edited. Let me try reading intro.md quickly, that's fine. +- button "读取 intro.md": + - img + - img + - text: 读取 + - button "intro.md" +- button "写入 src/util.ts +6 -0": + - img + - img + - text: 写入 + - button "src/util.ts" + - text: +6 -0 +- button "Bash Append done line to notes.txt": + - img + - img + - text: Bash Append done line to notes.txt +- button "编辑 intro.md +3 -3": + - img + - img + - text: 编辑 + - button "intro.md" + - text: +3 -3 +- button "思考 Verify the three results quickly.": + - img + - img + - text: 思考 Verify the three results quickly. +- button "Bash Verify all three changes": + - img + - img + - text: Bash Verify all three changes +- button "思考 Present the files as deliverables.": + - img + - img + - text: 思考 Present the files as deliverables. +- paragraph: 三件事都已完成并验证: +- list: + - listitem: + - strong: + - code: intro.md + - text: — 标题已从 + - code: "# 示例项目" + - text: 改为 + - code: "# 项目说明" + - text: (edit 工具需先读取文件以精确匹配,因此只读了这一个文件,未列目录)。 + - listitem: + - strong: + - code: src/util.ts + - text: (新建)— 导出带类型的 + - code: "add(a: number, b: number): number" + - text: 函数。 + - listitem: + - strong: + - code: notes.txt + - text: — 用 bash 以 + - code: ">>" + - text: 在末尾追加了 + - code: done + - text: (当前文件内容为 + - code: start + - text: / + - code: done + - text: 两行)。 +- button "交付文件 已交付 intro.md, src/util.ts, notes.txt": + - img + - text: 交付文件 已交付 intro.md, src/util.ts, notes.txt +- paragraph: + - text: 改动文件: + - code: + - button "在侧边栏打开 intro.md": intro.md + - text: 、 + - code: + - button "在侧边栏打开 src/util.ts": src/util.ts + - text: 、 + - code: + - button "在侧边栏打开 notes.txt": notes.txt + - text: 。 +- img +- text: 已编辑 3 个文件 +8 -1 +- list: + - listitem: + - button "在侧边栏打开 intro.md": intro.md +1 -1 + - listitem: + - button "在侧边栏打开 notes.txt": notes.txt +1 -0 + - listitem: + - button "在侧边栏打开 src/util.ts": src/util.ts +6 -0 +- text: 此主机没有可用的桌面,无法打开文件或文件夹 +- button "在侧边栏预览 intro.md" +- text: intro.md 标题已改为「项目说明」 +- button "在侧边栏打开 intro.md": 打开 +- button "intro.md 的更多文件操作" [disabled]: + - img +- button "在侧边栏预览 src/util.ts" +- text: util.ts 新建,导出 add 函数 +- button "在侧边栏打开 src/util.ts": 打开 +- button "src/util.ts 的更多文件操作" [disabled]: + - img +- button "在侧边栏预览 notes.txt" +- text: notes.txt 末尾已追加 done +- button "在侧边栏打开 notes.txt": 打开 +- button "notes.txt 的更多文件操作" [disabled]: + - img +- button "复制": + - img +- button "好的回答": + - img +- button "有问题的回答": + - img +- button "在新对话中分支": + - img +- button "用量 45.9K tok": + - img + - text: 用量 45.9K tok +- button "用时 {{duration}}": + - img + - text: 用时 {{duration}} +- text: {{clock}} diff --git a/snapshots/web/changed-files-turn/workspace.expected/intro.md b/snapshots/web/changed-files-turn/workspace.expected/intro.md new file mode 100644 index 0000000000..2e298a3bcb --- /dev/null +++ b/snapshots/web/changed-files-turn/workspace.expected/intro.md @@ -0,0 +1,3 @@ +# 项目说明 + +一个用于演示的仓库。 diff --git a/snapshots/web/changed-files-turn/workspace.expected/notes.txt b/snapshots/web/changed-files-turn/workspace.expected/notes.txt new file mode 100644 index 0000000000..60b5fd27b7 --- /dev/null +++ b/snapshots/web/changed-files-turn/workspace.expected/notes.txt @@ -0,0 +1,2 @@ +start +done diff --git a/snapshots/web/changed-files-turn/workspace.expected/src/util.ts b/snapshots/web/changed-files-turn/workspace.expected/src/util.ts new file mode 100644 index 0000000000..3643000795 --- /dev/null +++ b/snapshots/web/changed-files-turn/workspace.expected/src/util.ts @@ -0,0 +1,6 @@ +/** + * 两数相加 + */ +export function add(a: number, b: number): number { + return a + b; +} diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 6102d7ed25..07f4b35347 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -49,8 +49,6 @@ - strong: 总线连接 - text: :CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向 - paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。 -- text: 本轮文件改动 -- button "打开 {{cwd}}/workspace/von-neumann.svg": von-neumann.svg - text: 此主机没有可用的桌面,无法打开文件或文件夹 - button "在侧边栏预览 von-neumann.svg" - text: von-neumann.svg 冯诺依曼架构示意图 SVG diff --git a/tsconfig.host.json b/tsconfig.host.json index 73fc962699..281afb9db0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -86,6 +86,7 @@ "apps/web/tests/goal-command-presentation.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/changed-files.e2e.ts", + "apps/web/tests/changed-files-turn.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/subagent-interrupt.e2e.ts", From 51de5bbc4defd536364077b57d0a7bd571be6daa Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 17:25:50 +0800 Subject: [PATCH 04/37] fix(web): draw the changed-files header tile with the design's angle-bracket mark --- .../client/ui-deliverables/README.i18n.yaml | 4 +-- packages/client/ui-deliverables/README.md | 1 + packages/client/ui-deliverables/README.zh.md | 1 + .../src/client/ChangedFiles.tsx | 5 ++-- .../ui-deliverables/src/client/icons.tsx | 26 +++++++++++++++++++ 5 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 packages/client/ui-deliverables/src/client/icons.tsx diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 07425058cb..3884809d8c 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/ui-deliverables/README.md -README.md: 2af21786f4e66b96cc72e907e0791b1e263755c1 -README.zh.md: 609a82070b0ce5424fab2b4a9fe396f9b487df29 +README.md: 0effc0f608365c56dc79de77abcf7d0458f2f893 +README.zh.md: b338fc9cb590f002ecd99f1af045ef96f195ff35 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 2af21786f4..0effc0f608 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -100,6 +100,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. - **Folder opening needs a Host desktop** — without one the card header is inert and rows fall back to the right Sidebar's text preview, which shows files only. +- **Package-local header glyph** — the card's angle-bracket mark lives in `src/client/icons.tsx` until the shared icon set carries it; its props already match the shared icon contract. - **Files outside the workspace open by absolute path only** — the recorded path is the Host path at recording time; a moved workspace or a different viewing Session cannot relocate it. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 609a82070b..b338fc9cb5 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -100,6 +100,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 - **打开文件夹需要 Host 桌面**——没有桌面时卡片标题不可点击,行退回到右侧 Sidebar 的文本预览,该预览仅支持文件。 +- **包内的标题字形**——卡片的尖括号标记放在 `src/client/icons.tsx` 中,直到共享图标集收录它;其 props 已与共享图标契约一致。 - **工作区外的文件只按绝对路径打开**——记录的路径是记录时的 Host 路径;工作区移动或换一个查看 Session 都无法重新定位它。 diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 58342c6263..31c62446dd 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,12 +1,13 @@ /** The changed-files card: a folder-opening header, per-file line counts, and a three-row fold. */ import { useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' -import { IconChevronDownOutline14, IconChevronUpOutline14, IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' import { changedFileUrl } from '../changes.ts' import type { PresentedHost } from '../presented.ts' +import { IconCodeBracketsOutline16 } from './icons.tsx' import type { PresentedOpenPhase } from './present-open.ts' import type { ChangesTurnData } from './turn-deliverables.ts' import type { NS } from './locales.ts' @@ -64,7 +65,7 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op : folderPhase === 'opened' ? t('changes.folderOpened') : folderPhase === undefined ? undefined : t('changes.folderError') const summary = <> - + {t('changes.title', { count: String(changes.total) })} diff --git a/packages/client/ui-deliverables/src/client/icons.tsx b/packages/client/ui-deliverables/src/client/icons.tsx new file mode 100644 index 0000000000..82f52c96a7 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/icons.tsx @@ -0,0 +1,26 @@ +/** + * Package-local glyph for the changed-files card until the shared icon set + * carries it; its props already match the shared icon contract. + */ +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Angle brackets, the code mark the card's header tile shows. */ +export const IconCodeBracketsOutline16 = ({ size = 16, className }: IconProps) => ( + +) From 63f1f6eca470c07b83bcd8dd9624e6aca5800e47 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 17:29:37 +0800 Subject: [PATCH 05/37] chore(workspace-changes): align the package version with the root after the master merge --- packages/fs/workspace-changes/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fs/workspace-changes/package.json b/packages/fs/workspace-changes/package.json index bc40a763fd..8d9ea2a04c 100644 --- a/packages/fs/workspace-changes/package.json +++ b/packages/fs/workspace-changes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace-changes", "description": "Per-turn workspace file changes recorded from git working-tree snapshots for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, From d6634272d5b0dc5df4312dd39dcc5afc1f9eccef Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 10:55:15 +0800 Subject: [PATCH 06/37] feat(workspace-changes): keep snapshot objects in a private store under the Harness home Write every snapshot blob and tree into a per-repository object directory under DSH_HOME/workspace-changes with the repository's objects attached as a read-only alternate, so the user's repository gains no objects and the Harness owns retention through objectStoreMaxBytes. The placement follows the workspace change journal POC and costs nothing measurable. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 4 +- .../2026-09-11-turn-changed-files-card.zh.md | 4 +- apps/web/tests/scaffold.ts | 2 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 ++- docs/config-catalog.zh.md | 8 ++- .../fs/workspace-changes/README.i18n.yaml | 4 +- packages/fs/workspace-changes/README.md | 8 ++- packages/fs/workspace-changes/README.zh.md | 8 ++- packages/fs/workspace-changes/package.json | 2 + packages/fs/workspace-changes/src/git.ts | 65 +++++++++++++++---- packages/fs/workspace-changes/src/index.ts | 18 ++++- packages/fs/workspace-changes/src/recorder.ts | 6 +- .../fs/workspace-changes/tests/git.spec.ts | 14 ++-- .../tests/loader-composition.spec.ts | 2 + .../fs/workspace-changes/tests/plugin.spec.ts | 45 +++++++++++-- packages/fs/workspace-changes/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 19 files changed, 165 insertions(+), 47 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index d7567cc4df..9fa5a52d57 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: c477935fad1fa95ae0d60a67f31a89c38b020087 -2026-09-11-turn-changed-files-card.zh.md: 23ee760f18d6f9a6e9b540ef0e9bde452968a308 +2026-09-11-turn-changed-files-card.md: a14e062d7801de089a7644ae335d246a206fa655 +2026-09-11-turn-changed-files-card.zh.md: 0f69397b58543eeee79e5f5d57eb08aa31618494 diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index c477935fad..a14e062d78 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -12,7 +12,7 @@ After a turn, users want to see which files the model changed and by how much. T The Host [workspace-changes](../../../../packages/fs/workspace-changes/README.md) plugin records each top-level turn's changed files as a `workspace/changes` Session event, and the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) renders that event as the changed-files card in place of the mutation-call row. The event is log-only and never model-visible. -The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it. +The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a private object store under the Harness home while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects and the Harness owns retention; this placement follows the workspace change journal POC (#2973), which keeps every captured byte outside the workspace, and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it. Git is the default executable on `PATH`; no environment plugin is consulted. Only a working directory inside a git repository is recorded; outside any repository, or without git, the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. @@ -40,7 +40,7 @@ The recorder appends inside the turn on `agent/turn-stopping` and again after `t ## Consequences -Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the repository's object store; unreferenced trees are pruned by the repository's own garbage collection, after which only the recorded counts remain. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. +Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the private store; a store over its configured bound is discarded, after which only the recorded counts remain. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card only when their workspace is a git repository, which one dedicated scenario seeds. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index 23ee760f18..0f69397b58 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -12,7 +12,7 @@ Status: implemented Host 侧的 [workspace-changes](../../../../packages/fs/workspace-changes/README.zh.md) 插件把每个顶层轮次改动的文件记录为 `workspace/changes` Session 事件,[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)把该事件渲染为改动文件卡片,取代修改调用行。该事件仅写日志,模型永远看不到。 -记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。 +记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两者都写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 挂接,因此用户仓库不会多出任何对象,保留策略由 Harness 掌握;这个放置方式学自工作区变更日志 POC(#2973)把捕获的每个字节都放在工作区外的做法,而且因为 stat 缓存在 index 里,它对耗时没有可测量的影响。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、对象、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。 git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。 @@ -40,7 +40,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 ## Consequences -每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入仓库对象库;无引用的树由仓库自己的垃圾回收清理,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 +每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入私有对象库;超过配置上限的对象库会被丢弃,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只有在工作区是 git 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f56df34189..96ab2ca237 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -609,6 +609,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Implementation internals — click to expand -One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository, then `add --all` into a temporary index seeded from the repository's index and `write-tree` produce the tree id. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. The repository's index is only read. +One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository and its object store, then `add --all` into a temporary index seeded from the repository's index and `write-tree` produce the tree id. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the private store and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. The repository's index is only read. Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work. @@ -83,7 +85,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected. -- Snapshot trees are unreferenced objects, so a repository's garbage collection removes them after its prune expiry; the recorded counts survive, the trees needed for a later full-file comparison do not. +- Snapshot trees survive only until their private store exceeds `objectStoreMaxBytes` and is discarded; the recorded counts survive, the trees needed for a later full-file comparison do not. - Edits the user makes during a turn are attributed to that turn. - A working directory outside any git repository has no card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably. - Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only. diff --git a/packages/fs/workspace-changes/README.zh.md b/packages/fs/workspace-changes/README.zh.md index 1e400e422c..f8ac055711 100644 --- a/packages/fs/workspace-changes/README.zh.md +++ b/packages/fs/workspace-changes/README.zh.md @@ -35,11 +35,13 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| +| `dshHome` | `$DSH_HOME`,其次 `~/.dsh` | Harness home,其 `workspace-changes/` 目录为每个仓库存放一个快照对象库 | +| `objectStoreMaxBytes` | `1073741824` | 单个仓库的快照对象库允许的字节数,超过后丢弃并从空开始 | | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 | | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 | | `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 | -工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入仓库的对象库,仓库自己的 index、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 +工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。对象库超过 `objectStoreMaxBytes` 时在下次快照前被丢弃,从空开始。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。 @@ -53,7 +55,7 @@ kind: "package-reference"
实现细节——点击展开 -每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库,然后以仓库 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。仓库的 index 只读取。 +每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库及其对象库,然后以仓库 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向私有对象库、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。仓库的 index 只读取。 git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作。 @@ -83,7 +85,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ -- 快照树是无引用对象,仓库的垃圾回收会在其过期期限后删除它们;记录的计数保留,而将来做整文件对比所需的树不会保留。 +- 快照树只保留到其私有对象库超过 `objectStoreMaxBytes` 被丢弃为止;记录的计数保留,而将来做整文件对比所需的树不会保留。 - 用户在轮次进行中自己做的编辑会被算到该轮。 - 不在任何 git 仓库内的工作目录没有卡片;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。 - 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。 diff --git a/packages/fs/workspace-changes/package.json b/packages/fs/workspace-changes/package.json index 8d9ea2a04c..5c93a3bfe2 100644 --- a/packages/fs/workspace-changes/package.json +++ b/packages/fs/workspace-changes/package.json @@ -38,6 +38,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^" @@ -47,6 +48,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/packages/fs/workspace-changes/src/git.ts b/packages/fs/workspace-changes/src/git.ts index f4b4d23ad6..6c59fcda28 100644 --- a/packages/fs/workspace-changes/src/git.ts +++ b/packages/fs/workspace-changes/src/git.ts @@ -1,5 +1,6 @@ /** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */ -import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { copyFile, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' @@ -88,33 +89,67 @@ function ok(result: GitRunResult, what: string): GitRunResult { return result } -/** The repository enclosing a Session working directory. */ +/** The repository enclosing a Session working directory and the private store its snapshots write to. */ export interface GitWorkspace { /** Repository top-level directory, the root every diff path is relative to. */ root: string - /** Absolute git directory holding the index and objects. */ + /** Absolute git directory holding the repository's index. */ gitDir: string + /** Directory under the Harness home that receives every snapshot blob and tree. */ + objectsDir: string + /** Environment that routes object writes to {@link objectsDir} and object reads through the repository's store. */ + env: Readonly> +} + +/** Placement and bound of the private snapshot object stores. */ +export interface ObjectStoreOptions { + /** Directory that holds one object store per repository. */ + home: string + /** Bytes one repository's store may hold; a larger store is discarded before the next snapshot. */ + maxBytes: number +} + +/** Total size of the regular files under a directory; zero when it does not exist. */ +async function directoryBytes(directory: string): Promise { + let total = 0 + const entries = await readdir(directory, { recursive: true, withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (entry.isFile()) total += (await stat(join(entry.parentPath, entry.name))).size + } + return total } /** - * Locate the repository enclosing a working directory. + * Locate the repository enclosing a working directory and prepare its snapshot + * object store. The repository's own object store is attached read-only as an + * alternate, so snapshots read committed content from it and write nothing + * into it; a store over its byte bound is discarded and starts empty. * @param git - command runner. * @param cwd - absolute Session working directory. + * @param store - snapshot object store placement and bound. * @param signal - cancellation. * @returns the repository, or null when the directory is not inside one. */ -export async function locateGitWorkspace(git: GitRunner, cwd: string, signal: AbortSignal): Promise { - const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, signal }) +export async function locateGitWorkspace( + git: GitRunner, cwd: string, store: ObjectStoreOptions, signal: AbortSignal, +): Promise { + const found = await git.run([ + 'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-path', 'objects', + ], { cwd, signal }) if (found.exitCode !== 0) return null - const [root, gitDir] = found.stdout.split('\n') as [string, string] - return { root, gitDir } + const [root, gitDir, repositoryObjects] = found.stdout.split('\n') as [string, string, string] + const objectsDir = join(store.home, createHash('sha256').update(gitDir).digest('hex').slice(0, 16)) + if (await directoryBytes(objectsDir) > store.maxBytes) await rm(objectsDir, { recursive: true, force: true }) + await mkdir(objectsDir, { recursive: true }) + return { root, gitDir, objectsDir, env: { GIT_OBJECT_DIRECTORY: objectsDir, GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects } } } /** * Write the complete work tree, including untracked and modified files but * not ignored ones, as a tree object through a private index seeded from the - * repository's index. That index, the work tree, and every ref stay unchanged; - * an in-progress merge keeps its unmerged entries. + * repository's index. New blobs and the tree land in the private object store; + * the repository's index, object store, work tree, and refs stay unchanged, + * and an in-progress merge keeps its unmerged entries. * @param git - command runner. * @param workspace - addressed repository. * @param signal - cancellation. @@ -126,7 +161,7 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign const index = join(scratch, 'index') // A repository without an index yet (fresh `git init`) starts from scratch. await copyFile(join(workspace.gitDir, 'index'), index).then(() => undefined, () => undefined) - const env = { GIT_INDEX_FILE: index } + const env = { ...workspace.env, GIT_INDEX_FILE: index } ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`) return ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim() } finally { @@ -148,7 +183,9 @@ export async function diffTrees( git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal, ): Promise { if (before === after) return [] - const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { cwd: workspace.root, signal }), 'git diff-tree') + const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { + cwd: workspace.root, env: workspace.env, signal, + }), 'git diff-tree') if (result.truncated) throw new Error('git diff-tree output exceeded the configured cap') return parseNumstat(result.stdout) } @@ -167,7 +204,9 @@ export async function ignoredPaths( git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal, ): Promise> { if (paths.length === 0) return new Set() - const result = await git.run(['check-ignore', '-z', '--stdin'], { cwd: workspace.root, stdin: `${paths.join('\0')}\0`, signal }) + const result = await git.run(['check-ignore', '-z', '--stdin'], { + cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal, + }) if (result.exitCode === 1) return new Set() return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== '')) } diff --git a/packages/fs/workspace-changes/src/index.ts b/packages/fs/workspace-changes/src/index.ts index e6231f0fcf..44bcd4e80c 100644 --- a/packages/fs/workspace-changes/src/index.ts +++ b/packages/fs/workspace-changes/src/index.ts @@ -6,8 +6,10 @@ */ import { realpathSync } from 'node:fs' import { homedir } from 'node:os' +import { join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type {} from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-subprocess' @@ -24,8 +26,12 @@ export const name = 'workspace-changes' /** Services used to run git and observe turns. */ export const inject = ['subprocess'] -/** Snapshot bounds. Invalid values fail plugin load. */ +/** Snapshot bounds and object store placement. Invalid values fail plugin load. */ export interface Config { + /** Harness home whose `workspace-changes/` directory holds one snapshot object store per repository; `$DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Bytes one repository's snapshot object store may hold before it is discarded and restarted empty. */ + objectStoreMaxBytes: number /** Milliseconds one git command may run before the turn's record is abandoned. */ timeoutMs: number /** Bytes of git output retained per command; a larger diff listing abandons the record. */ @@ -36,6 +42,8 @@ export interface Config { /** Schemastery validation for {@link Config}. */ export const Config: z = z.object({ + dshHome: z.string(), + objectStoreMaxBytes: z.number().default(1024 * 1024 * 1024), timeoutMs: z.number().default(30_000), outputMaxBytes: z.number().default(8 * 1024 * 1024), maxFiles: z.number().default(500), @@ -77,7 +85,10 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise | undefined const gitRunner = (): Promise => { runner ??= resolveGit(ctx, lifetime.signal).then((executable) => { @@ -104,7 +116,7 @@ export function apply(ctx: Context, config: Config): void { let recorder = recorders.get(session) if (recorder === undefined) { recorder = new TurnRecorder(session, cwd, { - git: gitRunner(), home, temporaryRoots: roots, maxFiles: config.maxFiles, + git: gitRunner(), objects, home, temporaryRoots: roots, maxFiles: config.maxFiles, warn: (message) => { ctx.logger.warn(message) }, }) recorders.set(session, recorder) diff --git a/packages/fs/workspace-changes/src/recorder.ts b/packages/fs/workspace-changes/src/recorder.ts index 46b4facc0f..79d9735e63 100644 --- a/packages/fs/workspace-changes/src/recorder.ts +++ b/packages/fs/workspace-changes/src/recorder.ts @@ -3,7 +3,7 @@ import { realpath } from 'node:fs/promises' import { relative } from 'node:path' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts' +import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace, type ObjectStoreOptions } from './git.ts' import { fileDiffsOf, hunkLineCounts } from './numstat.ts' import { absolutePathOf, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, toPosix } from './paths.ts' import type { WorkspaceChangedFile } from './types.ts' @@ -12,6 +12,8 @@ import type { WorkspaceChangedFile } from './types.ts' export interface RecorderEnvironment { /** Resolves to the runner, or null when git is unavailable and no turn records anything. */ git: Promise + /** Where snapshot objects live and how large one repository's store may grow. */ + objects: ObjectStoreOptions /** Canonical absolute home directory abbreviated as `~` in display paths. */ home: string /** Temporary roots whose files never enter a summary. */ @@ -68,7 +70,7 @@ export class TurnRecorder { if (git === null) return // git reports symlink-resolved paths; every comparison uses that form. const cwd = await realpath(this.cwd) - const workspace = await locateGitWorkspace(git, cwd, signal) + const workspace = await locateGitWorkspace(git, cwd, this.env.objects, signal) if (workspace === null) return const tree = await snapshotTree(git, workspace, signal) this.baseline = { git, workspace, tree, cwd } diff --git a/packages/fs/workspace-changes/tests/git.spec.ts b/packages/fs/workspace-changes/tests/git.spec.ts index 7a391d729e..79ab0a868d 100644 --- a/packages/fs/workspace-changes/tests/git.spec.ts +++ b/packages/fs/workspace-changes/tests/git.spec.ts @@ -56,7 +56,7 @@ describe('snapshots and diffs', () => { expect(() => git(cwd, 'merge', 'side')).toThrow() expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt') const { git: runnerGit } = await runner() - const workspace = await locateGitWorkspace(runnerGit, cwd, signal) + const workspace = await locateGitWorkspace(runnerGit, cwd, { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }, signal) expect(workspace?.root).toBe(await realpath(cwd)) const tree = await snapshotTree(runnerGit, workspace!, signal) expect(tree).toMatch(/^[0-9a-f]{40,64}$/) @@ -66,13 +66,14 @@ describe('snapshots and diffs', () => { it('fails loudly when the addressed repository cannot be written or diffed', async () => { const cwd = await scratchDir('dsh-git-broken-', cleanups) const { git: runnerGit } = await runner() - expect(await locateGitWorkspace(runnerGit, cwd, signal)).toBeNull() - const broken = { root: cwd, gitDir: join(cwd, 'missing') } + const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 } + expect(await locateGitWorkspace(runnerGit, cwd, store, signal)).toBeNull() + const broken = { root: cwd, gitDir: join(cwd, 'missing'), objectsDir: join(cwd, 'missing-objects'), env: {} } await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in') await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed') expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set()) git(cwd, 'init', '-q') - const workspace = (await locateGitWorkspace(runnerGit, cwd, signal))! + const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))! await expect(diffTrees(runnerGit, workspace, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow('git diff-tree failed') await writeFile(join(cwd, 'many.txt'), Array.from({ length: 50 }, (_, index) => `line ${index}`).join('\n')) const before = await snapshotTree(runnerGit, workspace, signal) @@ -94,7 +95,10 @@ describe('TurnRecorder', () => { const warnings: string[] = [] let release!: (runner: GitRunner | null) => void const gate = new Promise((resolve) => { release = resolve }) - const env = { git: gate, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } } + const env = { + git: gate, objects: { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }, + home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) }, + } const disposed = new TurnRecorder(session, cwd, env) disposed.start(1) await new Promise(resolve => setTimeout(resolve, 5)) diff --git a/packages/fs/workspace-changes/tests/loader-composition.spec.ts b/packages/fs/workspace-changes/tests/loader-composition.spec.ts index 0a086da6fb..1d3f87f9a3 100644 --- a/packages/fs/workspace-changes/tests/loader-composition.spec.ts +++ b/packages/fs/workspace-changes/tests/loader-composition.spec.ts @@ -34,6 +34,8 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-subprocess-local'", "- name: '@deepseek-ai/dsh-workspace-changes'", + ' config:', + ` dshHome: ${JSON.stringify(join(root, 'home'))}`, '', ].join('\n')) context = new Context() diff --git a/packages/fs/workspace-changes/tests/plugin.spec.ts b/packages/fs/workspace-changes/tests/plugin.spec.ts index 1aa9da3345..413b805a96 100644 --- a/packages/fs/workspace-changes/tests/plugin.spec.ts +++ b/packages/fs/workspace-changes/tests/plugin.spec.ts @@ -1,5 +1,5 @@ /** The plugin records each top-level turn's file changes from real git snapshots. */ -import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -22,8 +22,14 @@ async function boot(config: Partial = {}) { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(LocalSubprocessRuntime) - const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config) - return { ctx, fiber } + const dshHome = config.dshHome ?? await scratchDir('dsh-workspace-changes-home-', cleanups) + const fiber = await ctx.plugin(WorkspaceChanges, { ...config, dshHome } as WorkspaceChanges.Config) + return { ctx, fiber, dshHome } +} + +/** Loose objects the repository itself holds. */ +function looseObjects(cwd: string): number { + return Number(git(cwd, 'count-objects').split(' ')[0]) } async function repository(): Promise { @@ -43,7 +49,8 @@ describe('workspace-changes in a repository', () => { const cwd = await repository() await writeFile(join(cwd, 'b.txt'), 'x user\n') await writeFile(join(cwd, 'u.txt'), 'user untracked\n') - const { ctx } = await boot() + const { ctx, dshHome } = await boot() + const repositoryObjects = looseObjects(cwd) const session = ctx.sessions.create(SessionId('repo'), { meta: { cwd } }) startTurn(session, 1) await settle(ctx, session) @@ -84,6 +91,36 @@ describe('workspace-changes in a repository', () => { expect(git(cwd, 'status', '--porcelain').split('\n').filter(Boolean).sort()).toEqual([ ' M a.txt', ' M b.txt', '?? bin.dat', '?? new.txt', '?? sub/', '?? u.txt', ]) + // Snapshot objects live under the Harness home; the repository's own store is untouched. + expect(looseObjects(cwd)).toBe(repositoryObjects) + const stores = await readdir(join(dshHome, 'workspace-changes')) + expect(stores).toHaveLength(1) + const objects = await readdir(join(dshHome, 'workspace-changes', stores[0]!), { recursive: true }) + expect(objects.some(entry => /^[0-9a-f]{2}\/[0-9a-f]{38,}$/.test(entry))).toBe(true) + }) + + it('discards a snapshot object store that outgrew its bound before the next baseline', async () => { + const cwd = await repository() + const { ctx, dshHome } = await boot({ objectStoreMaxBytes: 1 }) + const session = ctx.sessions.create(SessionId('bounded'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + const [store] = await readdir(join(dshHome, 'workspace-changes')) + const marker = join(dshHome, 'workspace-changes', store!, 'marker') + await writeFile(marker, 'old store') + await writeFile(join(cwd, 'n.txt'), 'n\n') + toolCall(session, 1, 'bash', { command: 'x' }) + endTurn(session, 1) + await settle(ctx, session) + expect(changes(session)).toHaveLength(1) + startTurn(session, 2) + await settle(ctx, session) + await expect(stat(marker)).rejects.toThrow() + await writeFile(join(cwd, 'm.txt'), 'm\n') + toolCall(session, 2, 'bash', { command: 'x' }) + endTurn(session, 2) + await settle(ctx, session) + expect(changes(session).at(-1)!.files.map(file => file.display)).toEqual(['m.txt']) }) it('places files above the working directory and outside the repository by their display rule', async () => { diff --git a/packages/fs/workspace-changes/tsconfig.json b/packages/fs/workspace-changes/tsconfig.json index a349b93708..b0d5795f1d 100644 --- a/packages/fs/workspace-changes/tsconfig.json +++ b/packages/fs/workspace-changes/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../subprocess/subprocess" + }, + { + "path": "../../util/home-paths" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ababbf38af..b176b19ea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6288,6 +6288,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm From cdfd8ab5b0f7886701a5509b11b16a3f41163626 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 10:59:38 +0800 Subject: [PATCH 07/37] test(web): refresh the changed-files golden for the decorative header glyph --- snapshots/web/changed-files-turn/ui.expected.md | 1 - 1 file changed, 1 deletion(-) diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index f77b7cf65f..621ed8672e 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -91,7 +91,6 @@ - code: - button "在侧边栏打开 notes.txt": notes.txt - text: 。 -- img - text: 已编辑 3 个文件 +8 -1 - list: - listitem: From 0d2fcf22dc75b40a0a816cf87190678dafbc64b8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 11:08:18 +0800 Subject: [PATCH 08/37] docs(persistence): refresh format facts after acknowledging the workspace/changes event --- docs/persistence-changes/historical-formats/README.i18n.yaml | 4 ++-- docs/persistence-changes/historical-formats/README.md | 2 +- docs/persistence-changes/historical-formats/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index b404f89378..9fbba4d64f 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/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 docs/persistence-changes/historical-formats/README.md -README.md: 8dc2caf4409e693c8b68c4e49db67b6c3234c238 -README.zh.md: 36095f9872ed6a2bd72d348be280f6d4534aeea7 +README.md: 859667d80e538253bfe83542d66168b8c3413b1a +README.zh.md: 954043f0411d38c4922e87351470e9c09e3af316 diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 8dc2caf440..859667d80e 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -31,7 +31,7 @@ The index is generated from validated snapshots and the current writer constant. | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.md) | [JSON](v0.schema.json) | 54 / 415 | | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | -| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 59 / 463 | +| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 60 / 469 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 36095f9872..954043f041 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -31,7 +31,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.zh.md) | [JSON](v0.schema.json) | 54 / 415 | | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | -| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 59 / 463 | +| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 60 / 469 | From 3010cb3dbad4cad5d035cb886305855add554365 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 11:18:52 +0800 Subject: [PATCH 09/37] fix(workspace-changes): resolve the types subpath to source and complete exported JSDoc Map @deepseek-ai/dsh-workspace-changes/types to src so the persistence catalog links source instead of built output, and document every exported function and recorder method. --- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 18 +++++------ docs/persistence-catalog.zh.md | 18 +++++------ docs/persistence-schema.json | 10 +++--- packages/fs/workspace-changes/src/paths.ts | 32 ++++++++++++++++--- packages/fs/workspace-changes/src/recorder.ts | 15 +++++++-- tsconfig.base.json | 1 + 7 files changed, 66 insertions(+), 32 deletions(-) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 75b7d6f9de..27b0e0e8d3 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: 0cb25147a1f12552be55480b1649e4cff74b508f -persistence-catalog.zh.md: 83057c6f5fe6278aadb4baff5bce313b57ea599e +persistence-catalog.md: aca13264f7bcf06f2c8acf7addc5c353c282723c +persistence-catalog.zh.md: aa4883e916dd32f7782a811f5873ae5ace4a55d8 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 0cb25147a1..aca13264f7 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -3991,7 +3991,7 @@ SHA-256: `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` | Property | Presence | Type | |---|---|---| -| `data` | required | [`packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangesData`](#persistence-type-packagesfsworkspace-changeslibtypestypesdtsworkspacechangesdata) | +| `data` | required | [`packages/fs/workspace-changes/src/types.ts#WorkspaceChangesData`](#persistence-type-packagesfsworkspace-changessrctypestsworkspacechangesdata) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -4003,7 +4003,7 @@ SHA-256: `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` SHA-256: `39c5a9c9f4688d412c567d287ae6577a3f38a73c3f8ba4deffc563d9ec93b927` -Array of [`packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangedFile`](#persistence-type-packagesfsworkspace-changeslibtypestypesdtsworkspacechangedfile). +Array of [`packages/fs/workspace-changes/src/types.ts#WorkspaceChangedFile`](#persistence-type-packagesfsworkspace-changessrctypestsworkspacechangedfile). @@ -4011,7 +4011,7 @@ Array of [`packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangedFi SHA-256: `f53ef94c56c9b1b890a1eba459ca22023d91f5f50bc829289181961ee052e4f7` -Sources: [`packages/fs/workspace-changes/lib/types/types.d.ts:28`](../packages/fs/workspace-changes/lib/types/types.d.ts) +Sources: [`packages/fs/workspace-changes/src/types.ts:30`](../packages/fs/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4825,13 +4825,13 @@ Sources: [`packages/fs/tool-present/src/types.ts:5`](../packages/fs/tool-present | `description` | optional | `string` | | `path` | required | `string` | - + -### `packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangedFile` +### `packages/fs/workspace-changes/src/types.ts#WorkspaceChangedFile` SHA-256: `14b37f44c06d8820b6601845f2ad7c995522044c58a6e87da04e29718f4835a1` -Sources: [`packages/fs/workspace-changes/lib/types/types.d.ts:3`](../packages/fs/workspace-changes/lib/types/types.d.ts) +Sources: [`packages/fs/workspace-changes/src/types.ts:4`](../packages/fs/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4841,13 +4841,13 @@ Sources: [`packages/fs/workspace-changes/lib/types/types.d.ts:3`](../packages/fs | `display` | required | `string` | | `path` | required | `string` | - + -### `packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangesData` +### `packages/fs/workspace-changes/src/types.ts#WorkspaceChangesData` SHA-256: `69ef2dbc5ed9134fd8199bf6da90da4bd78de61e9336ef008d1770101aa40ae2` -Sources: [`packages/fs/workspace-changes/lib/types/types.d.ts:20`](../packages/fs/workspace-changes/lib/types/types.d.ts) +Sources: [`packages/fs/workspace-changes/src/types.ts:22`](../packages/fs/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 83057c6f5f..aa4883e916 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -3993,7 +3993,7 @@ SHA-256: `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangesData`](#persistence-type-packagesfsworkspace-changeslibtypestypesdtsworkspacechangesdata) | +| `data` | 必需 | [`packages/fs/workspace-changes/src/types.ts#WorkspaceChangesData`](#persistence-type-packagesfsworkspace-changessrctypestsworkspacechangesdata) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -4005,7 +4005,7 @@ SHA-256: `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` SHA-256: `39c5a9c9f4688d412c567d287ae6577a3f38a73c3f8ba4deffc563d9ec93b927` -[`packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangedFile`](#persistence-type-packagesfsworkspace-changeslibtypestypesdtsworkspacechangedfile) 的数组。 +[`packages/fs/workspace-changes/src/types.ts#WorkspaceChangedFile`](#persistence-type-packagesfsworkspace-changessrctypestsworkspacechangedfile) 的数组。 @@ -4013,7 +4013,7 @@ SHA-256: `39c5a9c9f4688d412c567d287ae6577a3f38a73c3f8ba4deffc563d9ec93b927` SHA-256: `f53ef94c56c9b1b890a1eba459ca22023d91f5f50bc829289181961ee052e4f7` -来源:[`packages/fs/workspace-changes/lib/types/types.d.ts:28`](../packages/fs/workspace-changes/lib/types/types.d.ts) +来源:[`packages/fs/workspace-changes/src/types.ts:30`](../packages/fs/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4827,13 +4827,13 @@ SHA-256: `b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97` | `description` | 可选 | `string` | | `path` | 必需 | `string` | - + -### `packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangedFile` +### `packages/fs/workspace-changes/src/types.ts#WorkspaceChangedFile` SHA-256: `14b37f44c06d8820b6601845f2ad7c995522044c58a6e87da04e29718f4835a1` -来源:[`packages/fs/workspace-changes/lib/types/types.d.ts:3`](../packages/fs/workspace-changes/lib/types/types.d.ts) +来源:[`packages/fs/workspace-changes/src/types.ts:4`](../packages/fs/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4843,13 +4843,13 @@ SHA-256: `14b37f44c06d8820b6601845f2ad7c995522044c58a6e87da04e29718f4835a1` | `display` | 必需 | `string` | | `path` | 必需 | `string` | - + -### `packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangesData` +### `packages/fs/workspace-changes/src/types.ts#WorkspaceChangesData` SHA-256: `69ef2dbc5ed9134fd8199bf6da90da4bd78de61e9336ef008d1770101aa40ae2` -来源:[`packages/fs/workspace-changes/lib/types/types.d.ts:20`](../packages/fs/workspace-changes/lib/types/types.d.ts) +来源:[`packages/fs/workspace-changes/src/types.ts:22`](../packages/fs/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 487b0a4097..9bf4965668 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -18085,10 +18085,10 @@ ] }, "names": [ - "packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangedFile" + "packages/fs/workspace-changes/src/types.ts#WorkspaceChangedFile" ], "sources": [ - "packages/fs/workspace-changes/lib/types/types.d.ts:3" + "packages/fs/workspace-changes/src/types.ts:4" ] }, { @@ -34873,10 +34873,10 @@ ] }, "names": [ - "packages/fs/workspace-changes/lib/types/types.d.ts#WorkspaceChangesData" + "packages/fs/workspace-changes/src/types.ts#WorkspaceChangesData" ], "sources": [ - "packages/fs/workspace-changes/lib/types/types.d.ts:20" + "packages/fs/workspace-changes/src/types.ts:22" ] }, { @@ -59417,7 +59417,7 @@ "event:workspace/changes.data.snapshot" ], "sources": [ - "packages/fs/workspace-changes/lib/types/types.d.ts:28" + "packages/fs/workspace-changes/src/types.ts:30" ] }, { diff --git a/packages/fs/workspace-changes/src/paths.ts b/packages/fs/workspace-changes/src/paths.ts index 0e37704846..e7ed755a8c 100644 --- a/packages/fs/workspace-changes/src/paths.ts +++ b/packages/fs/workspace-changes/src/paths.ts @@ -3,12 +3,21 @@ import { realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { isAbsolute, relative, resolve, sep } from 'node:path' -/** Slash-separated form of a native relative path. */ +/** + * Slash-separated form of a native relative path. + * @param path - native path. + * @returns the same path with `/` separators. + */ export function toPosix(path: string): string { return path.split(sep).join('/') } -/** Whether `path` is `root` or lies under it. */ +/** + * Whether `path` is `root` or lies under it. + * @param root - absolute directory. + * @param path - absolute path to test. + * @returns true for the root itself and every descendant. + */ export function isInside(root: string, path: string): boolean { const rel = relative(root, path) return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) @@ -54,7 +63,14 @@ export function absolutePathOf(cwd: string, path: string): string { return resolve(cwd, path) } -/** Sort key and label rules; see `WorkspaceChangedFile.display`. */ +/** + * Sort key and label of a changed file; see `WorkspaceChangedFile.display`. + * @param absolute - canonical absolute file path. + * @param cwd - canonical Session working directory. + * @param root - repository top-level directory. + * @param home - canonical home directory, or empty to skip the `~` form. + * @returns the slash-separated display path. + */ export function displayPathOf(absolute: string, cwd: string, root: string, home: string): string { const rel = relative(cwd, absolute) if (!rel.startsWith('..') && !isAbsolute(rel)) return toPosix(rel) @@ -63,7 +79,12 @@ export function displayPathOf(absolute: string, cwd: string, root: string, home: return toPosix(absolute) } -/** The durable `path` field: relative inside the working directory, absolute elsewhere. */ +/** + * The durable `path` field: relative inside the working directory, absolute elsewhere. + * @param absolute - canonical absolute file path. + * @param cwd - canonical Session working directory. + * @returns the path the Web client opens the file through. + */ export function durablePathOf(absolute: string, cwd: string): string { const rel = relative(cwd, absolute) return !rel.startsWith('..') && !isAbsolute(rel) ? toPosix(rel) : absolute @@ -72,6 +93,9 @@ export function durablePathOf(absolute: string, cwd: string): string { /** * Code-unit order of display paths, which places `../` and absolute paths * before letters and matches git's own listing order for relative paths. + * @param a - first file. + * @param b - second file. + * @returns negative, zero, or positive as `Array.prototype.sort` expects. */ export function compareDisplay(a: { display: string }, b: { display: string }): number { return a.display < b.display ? -1 : a.display > b.display ? 1 : 0 diff --git a/packages/fs/workspace-changes/src/recorder.ts b/packages/fs/workspace-changes/src/recorder.ts index 79d9735e63..4f0a86eec0 100644 --- a/packages/fs/workspace-changes/src/recorder.ts +++ b/packages/fs/workspace-changes/src/recorder.ts @@ -58,7 +58,10 @@ export class TurnRecorder { private readonly env: RecorderEnvironment, ) {} - /** Open a turn: reset per-turn state and queue the baseline snapshot. */ + /** + * Open a turn: reset per-turn state and queue the baseline snapshot. + * @param turn - the turn number from `turn/start`. + */ start(turn: number): void { this.turn = turn this.baseline = null @@ -77,7 +80,10 @@ export class TurnRecorder { }) } - /** Remember a settled tool result and any file-tool hunks it carries. */ + /** + * Remember a settled tool result and any file-tool hunks it carries. + * @param event - the appended `tool/result` event. + */ observe(event: SessionEvent<'tool/result'>): void { if (event.data.turn !== this.turn) return this.lastToolResultSeq = event.seq @@ -101,7 +107,10 @@ export class TurnRecorder { return this.enqueue(signal => this.record(signal)) } - /** Record after `turn/end` when the in-turn record is missing or stale. */ + /** + * Record after `turn/end` when the in-turn record is missing or stale. + * @param turn - the turn number from `turn/end`. + */ end(turn: number): void { if (turn !== this.turn || this.recordedAfterSeq >= this.lastToolResultSeq) return void this.enqueue(signal => this.record(signal)) diff --git a/tsconfig.base.json b/tsconfig.base.json index 571ba0aa63..cec9395609 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -205,6 +205,7 @@ "@deepseek-ai/dsh-client-ui-conversation/client": ["./packages/client/ui-conversation/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"], "@deepseek-ai/dsh-tool-present/types": ["./packages/fs/tool-present/src/types.ts"], + "@deepseek-ai/dsh-workspace-changes/types": ["./packages/fs/workspace-changes/src/types.ts"], "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"], "@deepseek-ai/dsh-client-ui-input-trigger": ["./packages/client/ui-input-trigger/src"], From 8ea92ce9b5af81be3c40e556f2c811d5d6d92884 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 11:33:59 +0800 Subject: [PATCH 10/37] fix(workspace-changes): count argument-derived hunks, isolate per-turn state, and harden git handling Review round fixes: a write that creates a file persists no hunks and str_replace_editor persists none at all, so mutation calls now contribute hunks from their arguments when the result carries none; each turn owns a state object so a record still running for an interrupted turn cannot be reset by the next turn; git add accepts exit code 1 from --ignore-errors; rev-parse failures other than a missing repository fail loud; only a missing index or store is tolerated; the temporary-root exclusion uses the repository root; a row without a verified Host path previews in the Sidebar. The recorded scenario now creates an ignored file. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 4 +- .../2026-09-11-turn-changed-files-card.zh.md | 4 +- apps/web/tests/changed-files-turn.e2e.ts | 16 ++- .../src/client/ChangedFiles.tsx | 6 +- .../tests/deliverables.client.spec.tsx | 6 +- packages/fs/README.i18n.yaml | 4 +- packages/fs/README.md | 2 +- packages/fs/README.zh.md | 2 +- .../fs/workspace-changes/README.i18n.yaml | 4 +- packages/fs/workspace-changes/README.md | 9 +- packages/fs/workspace-changes/README.zh.md | 9 +- packages/fs/workspace-changes/src/git.ts | 32 +++-- packages/fs/workspace-changes/src/index.ts | 3 +- packages/fs/workspace-changes/src/numstat.ts | 56 ++++++++- packages/fs/workspace-changes/src/recorder.ts | 119 +++++++++++------- .../fs/workspace-changes/tests/git.spec.ts | 36 +++++- .../workspace-changes/tests/numstat.spec.ts | 29 ++++- .../fs/workspace-changes/tests/plugin.spec.ts | 76 +++++++---- .../web/changed-files-turn/session.v3.jsonl | 49 ++++---- .../web/changed-files-turn/ui.expected.md | 115 ++++++++++------- .../workspace.expected/.gitignore | 1 + .../workspace.expected/app.local | 1 + .../workspace.expected/src/util.ts | 3 - 24 files changed, 406 insertions(+), 184 deletions(-) create mode 100644 snapshots/web/changed-files-turn/workspace.expected/.gitignore create mode 100644 snapshots/web/changed-files-turn/workspace.expected/app.local diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index 9fa5a52d57..47c3810eeb 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: a14e062d7801de089a7644ae335d246a206fa655 -2026-09-11-turn-changed-files-card.zh.md: 0f69397b58543eeee79e5f5d57eb08aa31618494 +2026-09-11-turn-changed-files-card.md: e15273cf1685381295fd3d2e2ac40d5380ffe85b +2026-09-11-turn-changed-files-card.zh.md: da79843a48256d40472fcdec4b03f77cd0c2acfe diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index a14e062d78..e15273cf16 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -16,7 +16,7 @@ The recorder snapshots the working tree with git at turn start and turn end: `ad Git is the default executable on `PATH`; no environment plugin is consulted. Only a working directory inside a git repository is recorded; outside any repository, or without git, the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. -Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, so no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. +Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, or from the call's arguments when a result persists none, as a `write` that creates a file and every `str_replace_editor` mutation do; no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar. The event carries both snapshot tree ids for a later full-file comparison. @@ -44,4 +44,4 @@ Every turn with tool results costs two snapshots and one diff on the Host, and w The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card only when their workspace is a git repository, which one dedicated scenario seeds. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. -Focused tests cover repository and shadow tiers with real git, coverage classification, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenarios replay the recorder end to end. +Focused tests cover repositories with real git, directories outside any repository, coverage classification with the metadata the tools really persist, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index 0f69397b58..da79843a48 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -16,7 +16,7 @@ Host 侧的 [workspace-changes](../../../../packages/fs/workspace-changes/README git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。 -快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,因此不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 +快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,结果没有持久化 hunk 时取调用参数,新建文件的 `write` 和 `str_replace_editor` 的每种修改都是这种情况;不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。事件携带两个快照 tree id,供将来做整文件对比。 @@ -44,4 +44,4 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只有在工作区是 git 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 -聚焦测试用真实 git 覆盖仓库与影子两档、覆盖范围分类、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器。 +聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、按工具真实持久化的元数据做覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。 diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 3074805be7..98a7eb60bc 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -18,13 +18,14 @@ import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts' const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url)) const FIXTURE = join(DIR, 'session.v3.jsonl') const MODE = webSnapshotMode() -const PROMPT = '不用先查看目录,直接做三件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,然后用 bash 在 notes.txt 末尾追加一行 done。' +const PROMPT = '不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。' -/** Seed a committed repository so the turn's own edits are the only difference between its snapshots. */ +/** Seed a committed repository so the turn's own edits are the only difference between its snapshots; `*.local` stays ignored. */ async function seedRepository(cwd: string): Promise { await mkdir(cwd, { recursive: true }) await writeFile(join(cwd, 'intro.md'), '# 示例项目\n\n一个用于演示的仓库。\n') await writeFile(join(cwd, 'notes.txt'), 'start\n') + await writeFile(join(cwd, '.gitignore'), '*.local\n') const git = (...args: string[]) => execFileSync('git', ['-c', 'user.email=seed@example.com', '-c', 'user.name=seed', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'ignore' }) git('init', '-q', '-b', 'main') git('add', '-A') @@ -93,16 +94,19 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { const summary = summaries.at(-1) expect(summary, 'the turn must record its changed files').toBeDefined() if (summary === undefined) throw new Error('no changed-files summary') - expect(summary.data.files.map(file => file.display)).toEqual(['intro.md', 'notes.txt', 'src/util.ts']) - expect(summary.data.total).toBe(3) + // app.local is ignored by the repository, so its counts come from the write call rather than git. + expect(summary.data.files.map(file => file.display)).toEqual(['app.local', 'intro.md', 'notes.txt', 'src/util.ts']) + expect(summary.data.total).toBe(4) for (const file of summary.data.files) expect(file.added).toBeGreaterThan(0) - expect(summary.data.files[1]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 }) + expect(summary.data.files[0]).toMatchObject({ path: 'app.local', added: 1, deleted: 0 }) + expect(summary.data.files[2]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 }) expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n') const card = page.locator('[data-changed-files]') await card.waitFor({ state: 'visible' }) - expect(await card.getByText('已编辑 3 个文件', { exact: true }).count()).toBe(1) + expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1) expect(await card.getByRole('listitem').count()).toBe(3) + expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1) // Without a Host desktop the header is a label and rows preview in the Sidebar. expect(await card.getByRole('button', { name: '打开改动文件所在的文件夹' }).count()).toBe(0) expect(await card.getByRole('button', { name: '在侧边栏打开 notes.txt' }).count()).toBe(1) diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 31c62446dd..7e91eee2d0 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -85,11 +85,13 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op {rows.map((file, index) => { const phase = phases[changedFileUrl(sessionId, changes.seq, index)] const status = rowStatus(phase) + // A file without a verified Host path falls back to the Sidebar preview the status names. + const opensNatively = native && phase !== 'nativeUnavailable' return
  • + disabled={folder !== undefined && !folder.failed} onClick={() => { onOpen(null) }}>{summary} :
    {summary}
    }
      {rows.map((file, index) => { const phase = phases[changedFileUrl(sessionId, changes.seq, index)] - const status = rowStatus(phase) + const status = gesture(phase) // A file without a verified Host path falls back to the Sidebar preview the status names. const opensNatively = native && phase !== 'nativeUnavailable' return
    • diff --git a/packages/deliverables/workspace-changes/src/paths.ts b/packages/deliverables/workspace-changes/src/paths.ts index a18e5454fc..f207eb1e17 100644 --- a/packages/deliverables/workspace-changes/src/paths.ts +++ b/packages/deliverables/workspace-changes/src/paths.ts @@ -1,7 +1,7 @@ /** Path classification and display forms for changed files. */ import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { isAbsolute, relative, resolve, sep } from 'node:path' +import { isAbsolute, relative, sep } from 'node:path' /** * Slash-separated form of a native relative path. @@ -63,16 +63,6 @@ export function isTemporaryPath(path: string, roots: readonly string[]): boolean return roots.some(root => isInside(root, path)) } -/** - * Resolve a file-tool path against the Session working directory. - * @param cwd - absolute Session working directory. - * @param path - model-facing path, relative or absolute. - * @returns the absolute native path. - */ -export function absolutePathOf(cwd: string, path: string): string { - return resolve(cwd, path) -} - /** * Sort key and label of a changed file; see `WorkspaceChangedFile.display`. * @param absolute - canonical absolute file path. @@ -82,9 +72,7 @@ export function absolutePathOf(cwd: string, path: string): string { * @returns the slash-separated display path. */ export function displayPathOf(absolute: string, cwd: string, root: string, home: string): string { - const rel = relative(cwd, absolute) - if (!rel.startsWith('..') && !isAbsolute(rel)) return toPosix(rel) - if (isInside(root, absolute)) return toPosix(rel) + if (isInside(cwd, absolute) || isInside(root, absolute)) return toPosix(relative(cwd, absolute)) if (home !== '' && isInside(home, absolute)) return `~/${toPosix(relative(home, absolute))}` return toPosix(absolute) } @@ -96,8 +84,7 @@ export function displayPathOf(absolute: string, cwd: string, root: string, home: * @returns the path the Web client opens the file through. */ export function durablePathOf(absolute: string, cwd: string): string { - const rel = relative(cwd, absolute) - return !rel.startsWith('..') && !isAbsolute(rel) ? toPosix(rel) : absolute + return isInside(cwd, absolute) ? toPosix(relative(cwd, absolute)) : absolute } /** diff --git a/packages/deliverables/workspace-changes/src/recorder.ts b/packages/deliverables/workspace-changes/src/recorder.ts index fb24ec1530..c2fad1437f 100644 --- a/packages/deliverables/workspace-changes/src/recorder.ts +++ b/packages/deliverables/workspace-changes/src/recorder.ts @@ -1,12 +1,12 @@ /** Per-Session turn recorder: snapshot at turn start, diff and append at turn end. */ import { realpath } from 'node:fs/promises' import { homedir } from 'node:os' -import { relative } from 'node:path' +import { relative, resolve } from 'node:path' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { FileDiff } from '@deepseek-ai/dsh-tools' import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace, type ObjectStoreOptions } from './git.ts' import { argumentHunks, fileDiffsOf, hunkLineCounts } from './numstat.ts' -import { absolutePathOf, canonicalPath, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, temporaryRoots, toPosix } from './paths.ts' +import { canonicalPath, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, temporaryRoots, toPosix } from './paths.ts' import type { WorkspaceChangedFile } from './types.ts' /** Facts shared by every recorder of one plugin instance. */ @@ -180,33 +180,28 @@ export class TurnRecorder { private async record(state: TurnState, signal: AbortSignal): Promise { if (state.baseline === null || state.lastToolResultSeq < 0) return state.attemptedAfterSeq = state.lastToolResultSeq - const { git, workspace, tree: before, cwd, home, temporaryRoots: roots } = state.baseline + const baseline = state.baseline + const { git, workspace, tree: before, cwd } = baseline + const root = workspace.root const after = await snapshotTree(git, workspace, signal) const files = new Map() for (const entry of await diffTrees(git, workspace, before, after, signal)) { - const absolute = absolutePathOf(workspace.root, entry.path) - files.set(absolute, changedFile(absolute, cwd, home, workspace, entry)) + const absolute = resolve(root, entry.path) + files.set(absolute, changedFile(baseline, absolute, entry)) } + // File-tool hunks by canonical absolute path, for the files snapshots do not cover. const hunks = new Map() for (const [path, list] of state.hunks) { - const absolute = await canonicalPath(absolutePathOf(cwd, path)) - hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list]) + const absolute = await canonicalPath(resolve(cwd, path)) + if (!files.has(absolute)) hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list]) } - const inside: string[] = [] - const outside: string[] = [] - for (const absolute of hunks.keys()) { - if (files.has(absolute)) continue - // The repository is the user's workspace even when it lives under a temporary root. - if (!isInside(workspace.root, absolute) && isTemporaryPath(absolute, roots)) continue - if (isInside(workspace.root, absolute)) inside.push(absolute) - else outside.push(absolute) - } - const workTreePath = (absolute: string): string => toPosix(relative(workspace.root, absolute)) - const ignored = await ignoredPaths(git, workspace, inside.map(workTreePath), signal) - const toolOnly = new Set([...outside, ...inside.filter(absolute => ignored.has(workTreePath(absolute)))]) + const workTreePath = (absolute: string): string => toPosix(relative(root, absolute)) + const inRepository = [...hunks.keys()].filter(absolute => isInside(root, absolute)) + const ignored = await ignoredPaths(git, workspace, inRepository.map(workTreePath), signal) for (const [absolute, list] of hunks) { - if (!toolOnly.has(absolute)) continue - files.set(absolute, changedFile(absolute, cwd, home, workspace, { ...hunkLineCounts(list), binary: false })) + // Inside the repository only ignored files are uncovered; outside it, scratch files under a temporary root stay out. + const uncovered = isInside(root, absolute) ? ignored.has(workTreePath(absolute)) : !isTemporaryPath(absolute, baseline.temporaryRoots) + if (uncovered) files.set(absolute, changedFile(baseline, absolute, { ...hunkLineCounts(list), binary: false })) } const sorted = [...files.values()].sort(compareDisplay) // An empty list after an earlier in-turn record supersedes that record. @@ -219,11 +214,10 @@ export class TurnRecorder { }) state.recordedAfterSeq = event.seq } - } function changedFile( - absolute: string, cwd: string, home: string, workspace: GitWorkspace, counts: { added: number; deleted: number; binary: boolean }, + { cwd, workspace, home }: Baseline, absolute: string, counts: { added: number; deleted: number; binary: boolean }, ): WorkspaceChangedFile { return { path: durablePathOf(absolute, cwd), From 722008aeeb2e3394b24ef559bce7baa0df33d25a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 14:16:59 +0800 Subject: [PATCH 17/37] docs(deliverables): add the subsystem page the package-group gate requires The deliverables group README links a subsystems page that carries the PresentedFile, WorkspaceChangedFile, and WorkspaceChangesData declarations and names the two log-only events; the persistence catalog links the payload types to it. --- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 1 + docs/subsystems/README.zh.md | 1 + docs/subsystems/deliverables.i18n.yaml | 6 +++ docs/subsystems/deliverables.md | 61 ++++++++++++++++++++++++++ docs/subsystems/deliverables.zh.md | 61 ++++++++++++++++++++++++++ packages/deliverables/README.i18n.yaml | 4 +- packages/deliverables/README.md | 1 + packages/deliverables/README.zh.md | 1 + scripts/gen-persistence-catalog.ts | 2 + scripts/type-equiv.manifest.json | 15 +++++++ 14 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 docs/subsystems/deliverables.i18n.yaml create mode 100644 docs/subsystems/deliverables.md create mode 100644 docs/subsystems/deliverables.zh.md diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 9a3accc299..c0cd08d5b7 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: ee210f58bdf633dad75894131e649eff45158453 -persistence-catalog.zh.md: 10fd5874529228a87baed64f4bfd5ac8743f5e62 +persistence-catalog.md: 3011a1ec612f0e77925793e07ab07a567eb78b40 +persistence-catalog.zh.md: c1ca737cf0e1eba772b14dd00c93121b25e1339c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index ee210f58bd..3011a1ec61 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -477,7 +477,7 @@ Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compactio 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` -Types: [ToolCallId](subsystems/core.md) +Types: [PresentedFile](subsystems/deliverables.md) · [ToolCallId](subsystems/core.md) Source: [`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliverables/tool-present/src/types.ts) @@ -1252,6 +1252,8 @@ Source: [`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/ 'workspace/changes': WorkspaceChangesData ``` +Types: [WorkspaceChangesData](subsystems/deliverables.md) + Source: [`packages/deliverables/workspace-changes/src/types.ts:36`](../packages/deliverables/workspace-changes/src/types.ts) ## Resolved persistence types diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 10fd587452..c1ca737cf0 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -479,7 +479,7 @@ export type SessionEvent = { 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` -类型:[ToolCallId](subsystems/core.zh.md) +类型:[PresentedFile](subsystems/deliverables.zh.md) · [ToolCallId](subsystems/core.zh.md) 来源:[`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliverables/tool-present/src/types.ts) @@ -1254,6 +1254,8 @@ export type SessionEvent = { 'workspace/changes': WorkspaceChangesData ``` +类型:[WorkspaceChangesData](subsystems/deliverables.zh.md) + 来源:[`packages/deliverables/workspace-changes/src/types.ts:36`](../packages/deliverables/workspace-changes/src/types.ts) ## 已解析的持久化类型 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 4605553c60..080eff9a15 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/README.md -README.md: 88b2c85c2a02c98c57c3dd9075cec7d7dcd5ea9f -README.zh.md: 5527ff47c951594a99d01498548f17e726c2c095 +README.md: de98afec0af29a068fe4dc939802f479373240d5 +README.zh.md: 362398a368737c8ac34001a6133976ab5a376034 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 88b2c85c2a..de98afec0a 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -14,6 +14,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | | [schedule.md](schedule.md) | Session-local reminder records, durable transitions, active views, and ordinary-conversation delivery | | [todo.md](todo.md) | the todo package's whole-list item type, durable event ownership, projection, and open-turn invariant | +| [deliverables.md](deliverables.md) | what a turn hands to the user: `PresentedFile` deliveries from `present` and the `WorkspaceChangesData` summary of changed files from git snapshots | | [commands.md](commands.md) | the human-command registry service: definitions, adapter discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, the JSONL provider, `session/flush`, crash recovery, `SessionHeader` | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 5527ff47c9..362398a368 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -14,6 +14,7 @@ | [goal.md](goal.zh.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [schedule.md](schedule.zh.md) | 仅限会话内的提醒记录、持久转换、活动视图与普通对话交付 | | [todo.md](todo.zh.md) | todo 包的整列表条目类型、持久事件所有权、投影和未结束轮次不变式 | +| [deliverables.md](deliverables.zh.md) | 一轮交给用户的东西:`present` 声明的 `PresentedFile` 交付,以及由 git 快照得出的 `WorkspaceChangesData` 改动摘要 | | [commands.md](commands.zh.md) | 人类命令注册表服务:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.zh.md) | 完整的 `SessionEventMap` 变体目录、`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.zh.md) | 持久性 seam:`SessionPersistence`、JSONL 提供方、`session/flush`、崩溃恢复、`SessionHeader` | diff --git a/docs/subsystems/deliverables.i18n.yaml b/docs/subsystems/deliverables.i18n.yaml new file mode 100644 index 0000000000..9975a4c5d3 --- /dev/null +++ b/docs/subsystems/deliverables.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 docs/subsystems/deliverables.md +deliverables.md: f8e2b34d0f57537a8cc2e97331f1bf04525ddb4e +deliverables.zh.md: da8d7a93d282ab4a0bd02f5e5eadcd4a0f75703b diff --git a/docs/subsystems/deliverables.md b/docs/subsystems/deliverables.md new file mode 100644 index 0000000000..f8e2b34d0f --- /dev/null +++ b/docs/subsystems/deliverables.md @@ -0,0 +1,61 @@ +# Deliverables + +English | [中文](deliverables.zh.md) + +What a turn hands to the user, recorded as two log-only Session events owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end. Only clients read the events; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md). + +Sources: [`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts), [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) + +## `PresentedFile` — one declared delivery + +```ts type-equiv +/** A declared filesystem file whose current contents remain at its source path. */ +interface PresentedFile { + /** Original absolute path or path relative to the Session working directory. */ + path: string + /** Optional description supplied by the model. */ + description?: string +} +``` + +## `WorkspaceChangedFile` — one changed file + +```ts type-equiv +/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */ +interface WorkspaceChangedFile { + /** Path relative to the Session working directory, or an absolute Host path outside it. */ + path: string + /** + * Sort key and label: the relative path inside the working directory, a + * `../` path for repository files above it, a `~` path under the home + * directory, otherwise the absolute path. Always slash-separated. + */ + display: string + /** Lines added; zero for a binary file. */ + added: number + /** Lines deleted; zero for a binary file. */ + deleted: number + /** Present when git reported the file as binary. */ + binary?: true +} +``` + +## `WorkspaceChangesData` — one turn's change summary + +```ts type-equiv +/** Files changed during one top-level turn. */ +interface WorkspaceChangesData { + /** The turn whose file changes this summary describes. */ + turn: number + /** Changed files in `display` order, capped at the plugin's `maxFiles`. */ + files: WorkspaceChangedFile[] + /** Complete changed-file count, including files omitted by the cap. */ + total: number + /** Git tree object ids of the turn-start and turn-end working-tree snapshots. */ + snapshot: { before: string; after: string } +} +``` + +## Durable events + +`tool-present` declaration-merges `deliverables/presented: { turn; callId; files: PresentedFile[] }` into `SessionEventMap`, appended once per successful final `present` result. `workspace-changes` merges `workspace/changes: WorkspaceChangesData`, appended when a top-level turn stops; a later event for the same turn replaces the earlier one, so a client keeps only the latest. The generated [persistence catalog](../persistence-catalog.md#deliverablespresented--log-only) records both declaration sites. Neither event reaches the model. diff --git a/docs/subsystems/deliverables.zh.md b/docs/subsystems/deliverables.zh.md new file mode 100644 index 0000000000..da8d7a93d2 --- /dev/null +++ b/docs/subsystems/deliverables.zh.md @@ -0,0 +1,61 @@ +# 产出物 + +[English](deliverables.md) | 中文 + +记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有的两个只写日志的 Session 事件承载:模型通过 `present` 工具声明的文件,以及这一轮改动的文件,后者由轮开始和轮结束时的 git 工作树快照对比得出。事件只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。 + +源码:[`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts)、[`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) + +## `PresentedFile`:一条声明的交付 + +```ts type-equiv +/** A declared filesystem file whose current contents remain at its source path. */ +interface PresentedFile { + /** Original absolute path or path relative to the Session working directory. */ + path: string + /** Optional description supplied by the model. */ + description?: string +} +``` + +## `WorkspaceChangedFile`:一个改动的文件 + +```ts type-equiv +/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */ +interface WorkspaceChangedFile { + /** Path relative to the Session working directory, or an absolute Host path outside it. */ + path: string + /** + * Sort key and label: the relative path inside the working directory, a + * `../` path for repository files above it, a `~` path under the home + * directory, otherwise the absolute path. Always slash-separated. + */ + display: string + /** Lines added; zero for a binary file. */ + added: number + /** Lines deleted; zero for a binary file. */ + deleted: number + /** Present when git reported the file as binary. */ + binary?: true +} +``` + +## `WorkspaceChangesData`:一轮的改动摘要 + +```ts type-equiv +/** Files changed during one top-level turn. */ +interface WorkspaceChangesData { + /** The turn whose file changes this summary describes. */ + turn: number + /** Changed files in `display` order, capped at the plugin's `maxFiles`. */ + files: WorkspaceChangedFile[] + /** Complete changed-file count, including files omitted by the cap. */ + total: number + /** Git tree object ids of the turn-start and turn-end working-tree snapshots. */ + snapshot: { before: string; after: string } +} +``` + +## 持久事件 + +`tool-present` 通过声明合并把 `deliverables/presented: { turn; callId; files: PresentedFile[] }` 加入 `SessionEventMap`,每次 `present` 的最终结果成功时追加一条。`workspace-changes` 合并 `workspace/changes: WorkspaceChangesData`,在顶层轮停止时追加;同一轮后来的事件替代先前的,客户端只保留最新一条。生成的[持久化目录](../persistence-catalog.zh.md#deliverablespresented--log-only)记录了两处声明位置。两个事件都不会进入模型请求。 diff --git a/packages/deliverables/README.i18n.yaml b/packages/deliverables/README.i18n.yaml index a0fbfda878..c5873b1ab9 100644 --- a/packages/deliverables/README.i18n.yaml +++ b/packages/deliverables/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/deliverables/README.md -README.md: 4295e15981c2e01465de301edf1f1182aa21b88a -README.zh.md: 37eab8dc0d16a188aeb85be03b4572a70a28cb49 +README.md: ff2980f8cd190281010fcce3cd5c8a27f7d4213d +README.zh.md: c9bf60f3e765214608ed4d1d1fc25b1a2115460e diff --git a/packages/deliverables/README.md b/packages/deliverables/README.md index 4295e15981..ff2980f8cd 100644 --- a/packages/deliverables/README.md +++ b/packages/deliverables/README.md @@ -32,6 +32,7 @@ The deliverables family records what a turn hands to the user as durable Session ## Related documentation +- [Deliverables subsystem](../../docs/subsystems/deliverables.md) — the `PresentedFile` and `WorkspaceChangesData` vocabulary and the two durable events. - [Web deliverables](../client/ui-deliverables/README.md) — the turn-tail cards and file mentions that render these events. - [Present declares workspace source files](../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md) — the delivery decision. - [Turn changed-files card](../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the snapshot design and coverage rules. diff --git a/packages/deliverables/README.zh.md b/packages/deliverables/README.zh.md index 37eab8dc0d..c9bf60f3e7 100644 --- a/packages/deliverables/README.zh.md +++ b/packages/deliverables/README.zh.md @@ -32,6 +32,7 @@ deliverables 系列把一轮交给用户的内容记录为只有客户端读取 ## 相关文档 +- [产出物子系统](../../docs/subsystems/deliverables.zh.md)——`PresentedFile` 与 `WorkspaceChangesData` 的词汇和两个持久事件。 - [Web 产出物](../client/ui-deliverables/README.zh.md)——渲染这些事件的轮尾卡片与文件提及。 - [present 声明工作区源文件](../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md)——交付决策。 - [本轮改动文件卡片](../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计与覆盖规则。 diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 7f0be31113..23c72f13d1 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -40,6 +40,8 @@ const LINK_MAP: Record = { StreamChunk: 'subsystems/llm-streaming.md', TokenUsage: 'subsystems/llm-streaming.md', TodoItem: 'subsystems/todo.md', + PresentedFile: 'subsystems/deliverables.md', + WorkspaceChangesData: 'subsystems/deliverables.md', TurnTrigger: 'subsystems/session.md', TurnEndReason: 'subsystems/session.md', SessionTitleEventData: 'subsystems/session-title.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 6b0d56a403..3bbabf8097 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2161,6 +2161,21 @@ "doc": "docs/subsystems/mcp.md", "symbol": "McpResourceProvider", "source": "packages/mcp/mcp-resources/src/index.ts" + }, + { + "doc": "docs/subsystems/deliverables.md", + "symbol": "PresentedFile", + "source": "packages/deliverables/tool-present/src/types.ts" + }, + { + "doc": "docs/subsystems/deliverables.md", + "symbol": "WorkspaceChangedFile", + "source": "packages/deliverables/workspace-changes/src/types.ts" + }, + { + "doc": "docs/subsystems/deliverables.md", + "symbol": "WorkspaceChangesData", + "source": "packages/deliverables/workspace-changes/src/types.ts" } ] } From dde4dccbd2ff5356875772d791f088d5c5d86488 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 18:21:12 +0800 Subject: [PATCH 18/37] feat(workspace-changes): keep change summaries on the Host for the Session's lifetime The workspace/changes event now carries only the turn number. The recorder keeps each announced summary in memory under the event's sequence and serves it through the new workspaceChanges service; the Web deliverables plugin reads it once per announcement through an authenticated summary route and renders the card only while the Host still serves it, so a conversation reopened after a Host restart has no card for its earlier turns. Snapshot objects move from a shared, size-bounded store under the Harness home to a temporary directory per Session that disposal removes, which retires the dshHome and objectStoreMaxBytes fields. The cold-seeded fold scenario goes with the logged file list; the client spec covers the fold and the recorded scenario covers the served summary. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 18 +- .../2026-09-11-turn-changed-files-card.zh.md | 18 +- apps/web/tests/changed-files-turn.e2e.ts | 21 +- apps/web/tests/changed-files.e2e.ts | 148 ----- apps/web/tests/changed-files.overlay.yml | 9 - apps/web/tests/scaffold.ts | 2 - apps/web/tsconfig.json | 1 - docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 + docs/capability-seams.zh.md | 4 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 12 +- docs/config-catalog.zh.md | 10 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 72 +-- docs/persistence-catalog.zh.md | 72 +-- ...26-09-14-workspace-changes-event.i18n.yaml | 4 +- .../2026-09-14-workspace-changes-event.md | 2 +- ...-09-14-workspace-changes-event.schema.json | 84 +-- .../2026-09-14-workspace-changes-event.zh.md | 2 +- .../historical-formats/README.i18n.yaml | 4 +- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 545 +++--------------- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 2 +- docs/subsystems/README.zh.md | 2 +- docs/subsystems/deliverables.i18n.yaml | 4 +- docs/subsystems/deliverables.md | 60 +- docs/subsystems/deliverables.zh.md | 60 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 8 +- packages/client/ui-deliverables/README.zh.md | 8 +- .../client/ui-deliverables/src/changes.ts | 59 +- .../src/client/ChangedFiles.tsx | 6 +- .../src/client/Deliverables.tsx | 34 +- .../src/client/changes-summary.ts | 60 ++ .../ui-deliverables/src/client/index.ts | 12 +- .../src/client/turn-deliverables.ts | 21 +- packages/client/ui-deliverables/src/index.ts | 5 +- .../ui-deliverables/src/present-open.ts | 39 +- .../tests/changes-open.host.spec.ts | 78 ++- .../tests/deliverables.client.spec.tsx | 112 +++- .../ui-deliverables/tests/prompt.host.spec.ts | 1 + packages/deliverables/README.i18n.yaml | 4 +- packages/deliverables/README.md | 2 +- packages/deliverables/README.zh.md | 2 +- .../workspace-changes/README.i18n.yaml | 4 +- .../deliverables/workspace-changes/README.md | 23 +- .../workspace-changes/README.zh.md | 23 +- .../workspace-changes/package.json | 2 - .../deliverables/workspace-changes/src/git.ts | 47 +- .../workspace-changes/src/index.ts | 58 +- .../workspace-changes/src/recorder.ts | 60 +- .../workspace-changes/src/types.ts | 37 +- .../workspace-changes/tests/git.spec.ts | 50 +- .../tests/loader-composition.spec.ts | 8 +- .../workspace-changes/tests/plugin.spec.ts | 90 +-- .../workspace-changes/tests/support.ts | 11 +- .../extensions/tool-cordis/src/api-catalog.ts | 21 + pnpm-lock.yaml | 3 - scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 7 + scripts/gen-persistence-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 7 +- .../web/changed-files-turn/session.v3.jsonl | 2 +- tsconfig.host.json | 1 - 71 files changed, 865 insertions(+), 1242 deletions(-) delete mode 100644 apps/web/tests/changed-files.e2e.ts delete mode 100644 apps/web/tests/changed-files.overlay.yml create mode 100644 packages/client/ui-deliverables/src/client/changes-summary.ts diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index 261a6d6a40..30db80a4bb 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: aca452b356715fc2c5e1e1b7360c71d020c221b2 -2026-09-11-turn-changed-files-card.zh.md: 721819a608a75aff1e9aac03f20138f6dddaf055 +2026-09-11-turn-changed-files-card.md: 9e5ca9689e2af3a650f596bdd8da980396ddb4d4 +2026-09-11-turn-changed-files-card.zh.md: 4af94d62eb434ee244a80f63290c256d0960c826 diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index aca452b356..9e5ca9689e 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -10,17 +10,19 @@ After a turn, users want to see which files the model changed and by how much. T ## Decision -The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) plugin records each top-level turn's changed files as a `workspace/changes` Session event, and the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) renders that event as the changed-files card in place of the mutation-call row. The event is log-only and never model-visible. +The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) plugin summarizes each top-level turn's changed files, announces the summary with a `workspace/changes` Session event that carries only the turn number, and serves the summary through its `workspaceChanges` service until the Session is disposed; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) reads the served summary and renders it as the changed-files card in place of the mutation-call row. The event is log-only and never model-visible. -The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a private object store under the Harness home while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects and the Harness owns retention; this placement follows the workspace change journal POC (#2973), which keeps every captured byte outside the workspace, and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it. +The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a temporary object directory owned by the Session while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects; keeping every captured byte outside the workspace follows the workspace change journal POC (#2973) and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it. Git is the default executable on `PATH`; no environment plugin is consulted. Only a working directory inside a git repository is recorded; outside any repository, or without git, the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, or from the call's arguments when a result persists none, as a `write` that creates a file and every `str_replace_editor` mutation do; no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. -The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar. The event carries both snapshot tree ids for a later full-file comparison. +The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar. -The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered and the card renders from the log alone. The Client keeps the latest event per turn. +The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered. The Client keeps the latest announcement per turn and reads its summary once through the authenticated summary route. + +The log deliberately carries nothing but the turn number. Summaries, snapshot trees, and the tree ids a later content comparison needs live only as long as the Session in the Host process: the summaries in the recorder, the objects in a temporary directory removed on disposal. A conversation reopened after a Host restart has no card for its earlier turns. The product decision is that a card whose content the Host can no longer open should not appear at all, so the card's lifetime equals the content's lifetime rather than the log's. Whether to keep content across restarts, and for how many turns, is left to #3822. ## Alternatives considered @@ -32,7 +34,11 @@ The recorder appends inside the turn on `agent/turn-stopping` and again after `t **A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; the card is simply absent until git exists. -**Recording the pre-edit file content at first touch** would make hunk counts exact and enable full-file diffs for uncovered files, but requires the file tools to hand their pre-read content to the recorder; the tree ids and persisted hunks leave that open. +**Recording the pre-edit file content at first touch** would make hunk counts exact and enable full-file diffs for uncovered files, but requires the file tools to hand their pre-read content to the recorder; the persisted hunks already carry the before and after text of each edit for that purpose. + +**Recording the file list and counts in the event** was the first implementation: the card would then render from the log forever, while the content it opens would not survive. It was replaced by the announcement-only event so that the card and its content share one lifetime. + +**A snapshot object store under the Harness home with a byte bound** was the first implementation's placement: one store per repository, shared by every Session, discarded when it outgrew the bound. It survived Host restarts that the summaries no longer do, needed two configuration fields, and made a Session's snapshot fail when another Session discarded the store; a per-Session temporary directory removes all three. **An environment-provider seam for locating git** was raised by the team but not settled; the plugin uses `PATH` and keeps its lookup in one place. @@ -40,7 +46,7 @@ The recorder appends inside the turn on `agent/turn-stopping` and again after `t ## Consequences -Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the private store; a store over its configured bound is discarded, after which only the recorded counts remain. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. +Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the Session's temporary directory, which disposal removes. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card only when their workspace is a git repository, which one dedicated scenario seeds. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index 721819a608..4af94d62eb 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -10,17 +10,19 @@ Status: implemented ## Decision -Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 插件把每个顶层轮次改动的文件记录为 `workspace/changes` Session 事件,[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)把该事件渲染为改动文件卡片,取代修改调用行。该事件仅写日志,模型永远看不到。 +Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 插件汇总每个顶层轮次改动的文件,用只带轮号的 `workspace/changes` Session 事件宣告,并通过 `workspaceChanges` 服务提供摘要直到 Session 释放;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)读取提供的摘要并渲染为改动文件卡片,取代修改调用行。该事件仅写日志,模型永远看不到。 -记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两者都写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 挂接,因此用户仓库不会多出任何对象,保留策略由 Harness 掌握;这个放置方式学自工作区变更日志 POC(#2973)把捕获的每个字节都放在工作区外的做法,而且因为 stat 缓存在 index 里,它对耗时没有可测量的影响。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、对象、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。 +记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两者都写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 挂接,因此用户仓库不会多出任何对象;把捕获的每个字节都放在工作区外学自工作区变更日志 POC(#2973),而且因为 stat 缓存在 index 里,它对耗时没有可测量的影响。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、对象、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。 git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。 快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,结果没有持久化 hunk 时取调用参数,新建文件的 `write` 和 `str_replace_editor` 的每种修改都是这种情况;不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 -列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。事件携带两个快照 tree id,供将来做整文件对比。 +列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。 -记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖,卡片仅凭日志即可渲染。Client 保留每轮最新的事件。 +记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。 + +日志有意只携带轮号。摘要、快照树以及将来做内容对比要用的 tree id 只在 Host 进程内随 Session 存活:摘要在记录器里,对象在释放时删除的临时目录里。Host 重启后重新打开的对话,先前轮次没有卡片。产品上的决定是,Host 已经打不开内容的卡片根本不该出现,所以卡片的寿命等于内容的寿命,而不是日志的寿命。是否跨重启保留内容、保留多少轮,留给 #3822。 ## Alternatives considered @@ -32,7 +34,11 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 **为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片直接不显示。 -**在首次触碰时记录编辑前的文件内容**能让 hunk 计数精确并为未覆盖文件提供整文件 diff,但要求文件工具把预先读取的内容交给记录器;tree id 与持久化的 hunk 为此留出了空间。 +**在首次触碰时记录编辑前的文件内容**能让 hunk 计数精确并为未覆盖文件提供整文件 diff,但要求文件工具把预先读取的内容交给记录器;持久化的 hunk 已经为此带上了每次编辑的前后文本。 + +**把文件列表和行数记进事件**是第一版实现:卡片从此可以永远从日志渲染,而它打开的内容却活不了那么久。它被只做宣告的事件取代,让卡片和内容共用一个寿命。 + +**Harness home 下带字节上限的快照对象库**是第一版实现的放置方式:每个仓库一个、所有 Session 共用、超过上限就丢弃。它能活过 Host 重启,而摘要已经不再需要这一点;它还需要两个配置字段,并且一个 Session 丢弃对象库会让另一个 Session 的快照失败。每个 Session 一个临时目录把这三点一起去掉。 **用于定位 git 的环境提供者接缝**由团队提出但尚未定案;插件使用 `PATH` 并把查找收敛在一处。 @@ -40,7 +46,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 ## Consequences -每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入私有对象库;超过配置上限的对象库会被丢弃,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 +每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入 Session 的临时目录,释放时一并删除。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只有在工作区是 git 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 98a7eb60bc..90d636dcfd 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -90,16 +90,19 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { cwd = session.header.cwd if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) - const summaries = session.snapshotEvents().filter(event => event.type === 'workspace/changes') - const summary = summaries.at(-1) - expect(summary, 'the turn must record its changed files').toBeDefined() - if (summary === undefined) throw new Error('no changed-files summary') + const announced = session.snapshotEvents().filter(event => event.type === 'workspace/changes').at(-1) + expect(announced, 'the turn must announce its changed files').toBeDefined() + if (announced === undefined) throw new Error('no changed-files announcement') + expect(announced.data).toEqual({ turn: 1 }) + // The log carries only the turn; the Host serves the summary for the announcing event while the Session lives. + const summary = scaffold.ctx.workspaceChanges.summary(sessionId, announced.seq) + if (summary === undefined) throw new Error('the Host serves no summary for the announcement') // app.local is ignored by the repository, so its counts come from the write call rather than git. - expect(summary.data.files.map(file => file.display)).toEqual(['app.local', 'intro.md', 'notes.txt', 'src/util.ts']) - expect(summary.data.total).toBe(4) - for (const file of summary.data.files) expect(file.added).toBeGreaterThan(0) - expect(summary.data.files[0]).toMatchObject({ path: 'app.local', added: 1, deleted: 0 }) - expect(summary.data.files[2]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 }) + expect(summary.files.map(file => file.display)).toEqual(['app.local', 'intro.md', 'notes.txt', 'src/util.ts']) + expect(summary.total).toBe(4) + for (const file of summary.files) expect(file.added).toBeGreaterThan(0) + expect(summary.files[0]).toMatchObject({ path: 'app.local', added: 1, deleted: 0 }) + expect(summary.files[2]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 }) expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n') const card = page.locator('[data-changed-files]') diff --git a/apps/web/tests/changed-files.e2e.ts b/apps/web/tests/changed-files.e2e.ts deleted file mode 100644 index 173bac9d2d..0000000000 --- a/apps/web/tests/changed-files.e2e.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Web e2e scenario: the changed-files card a finished turn ends with. -// Cold-seeds one recorded change summary (zero model calls), then verifies the -// real assembled card folds after three rows, expands to the complete list, -// and offers native file and folder actions when the Host desktop is pinned on. -import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-title' -import type {} from '@deepseek-ai/dsh-workspace-changes' -import { - launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -const MODE = webSnapshotMode() -const OVERLAY = fileURLToPath(new URL('./changed-files.overlay.yml', import.meta.url)) -const SEED_ID = 'changed-files-web-e2e' -const DONE = 'CHANGED_FILES_DONE' - -/** Eleven files in display order, as the Host records them. */ -const CHANGED = [ - ['config/design-token', 42, 11], - ['config/feature-flags.json', 143, 32], - ['config/launch-plan.yaml', 654, 9], - ['src/app.ts', 120, 80], - ['src/index.html', 30, 4], - ['src/manifest.yaml', 12, 0], - ['src/notes.txt', 8, 8], - ['src/preview.svg', 0, 0], - ['src/schema.json', 90, 60], - ['src/styles.css', 100, 100], - ['关于我.md', 33, 22], -] as const - -/** Build one settled turn whose recorded summary lists eleven changed files. */ -function changedFixture(): string { - const session = Session.create(SessionId('changed-files-source')) - const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) - session.append('turn/start', { turn: 1 }) - const user = session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Update the site files.' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('session/title', { - title: 'Changed files fold', messageSeqs: [user.seq], source: { kind: 'fallback' }, - }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/message', { - stream: [], - turn: 1, - step: 1, - message: createAssistantMessage({ - content: [{ type: 'text', text: `Updated the site.\n\n${DONE}` }], - source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('workspace/changes', { - turn: 1, - total: CHANGED.length, - snapshot: { before: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', after: 'c1a2b3d4e5f60718293a4b5c6d7e8f9012345678' }, - files: CHANGED.map(([path, added, deleted]) => ({ - path, display: path, added, deleted, ...(path.endsWith('.svg') ? { binary: true as const } : {}), - })), - }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - - return [ - JSON.stringify({ - type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', - createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, - }), - ...session.snapshotEvents().map(event => JSON.stringify({ - ...event, time: eventTimeOrigin + event.seq * 1_000, - })), - '', - ].join('\n') -} - -describe('web e2e: a finished turn ends with the files it changed', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) - await seedSession(scaffold, changedFixture(), SEED_ID) - browser = await chromium.launch() - page = await newEnglishPage(browser) - await page.setViewportSize({ width: 1400, height: 900 }) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it.skipIf(MODE === 'record')('folds an eleven-file summary after three rows and expands it in place', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-changed-files')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() - - await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) - const card = page.locator('[data-changed-files]') - await card.waitFor({ timeout: 15_000 }) - await expect.poll(() => card.getByText('Edited 11 files', { exact: true }).count()).toBe(1) - expect(await card.getByText('+1,232', { exact: true }).count()).toBe(1) - expect(await card.getByText('-326', { exact: true }).count()).toBe(1) - const rows = card.getByRole('listitem') - await expect.poll(() => rows.count()).toBe(3) - expect(await rows.nth(0).innerText()).toContain('config/design-token') - expect(await rows.nth(0).innerText()).toContain('+42') - expect(await rows.nth(2).innerText()).toContain('config/launch-plan.yaml') - // The pinned desktop turns the header into the folder action and rows into default-app opens. - await expect.poll(() => card.getByRole('button', { name: 'Open the folder containing the changed files' }).count()).toBe(1) - expect(await card.getByRole('button', { name: 'Open config/design-token in default app' }).count()).toBe(1) - - const expand = card.getByRole('button', { name: 'Show all 11 changed files' }) - expect(await expand.innerText()).toContain('All 11 files') - await expand.click() - await expect.poll(() => rows.count()).toBe(11) - expect(await rows.nth(7).innerText()).toContain('binary') - expect(await rows.nth(10).innerText()).toContain('关于我.md') - const collapse = card.getByRole('button', { name: 'Collapse changed files' }) - expect(await collapse.getAttribute('aria-expanded')).toBe('true') - // The collapse control is the card's last element after expansion. - expect(await card.evaluate(element => element.lastElementChild?.getAttribute('aria-expanded'))).toBe('true') - await collapse.click() - await expect.poll(() => rows.count()).toBe(3) - expect(await page.getByText('Files changed', { exact: true }).count()).toBe(0) - - const geometry = await card.evaluate(element => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth })) - expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth) - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }, 90_000) -}) diff --git a/apps/web/tests/changed-files.overlay.yml b/apps/web/tests/changed-files.overlay.yml deleted file mode 100644 index 165251842b..0000000000 --- a/apps/web/tests/changed-files.overlay.yml +++ /dev/null @@ -1,9 +0,0 @@ -# The card test asserts the native folder and file actions without launching them. Pin -# the capability so headless Linux CI and desktop developer hosts expose the -# same UI branch; platform opener behavior belongs to the Host unit tests. -- id: session-controller - config: - nativeOpen: true -- id: settings-controller - config: - nativeOpen: true diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 30df0031b9..23d133158a 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -609,8 +609,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): PromiseHost settings-surface Remote controller"] pkg_api_workspace_files["api-workspace-files"] svc_workspaceFiles["ctx.workspaceFiles
      Host workspace file Remote service"] + pkg_workspace_changes["workspace-changes"] + svc_workspaceChanges["ctx.workspaceChanges
      Host per-turn changed-file summaries"] pkg_api_terminal_controller["api-terminal-controller"] svc_terminalController["ctx.terminalController
      Session interactive terminal Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] @@ -369,6 +371,7 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_ptc --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + pkg_workspace_changes --> svc_workspaceChanges svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base @@ -524,6 +527,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. | +| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn. | | `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index eb6088bf63..4184a8ca2f 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -60,6 +60,8 @@ flowchart LR svc_settingsController["ctx.settingsController
      Host settings-surface Remote controller"] pkg_api_workspace_files["api-workspace-files"] svc_workspaceFiles["ctx.workspaceFiles
      Host workspace file Remote service"] + pkg_workspace_changes["workspace-changes"] + svc_workspaceChanges["ctx.workspaceChanges
      Host per-turn changed-file summaries"] pkg_api_terminal_controller["api-terminal-controller"] svc_terminalController["ctx.terminalController
      Session interactive terminal Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] @@ -371,6 +373,7 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_ptc --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + pkg_workspace_changes --> svc_workspaceChanges svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base @@ -526,6 +529,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 | +| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn. | | `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | 通过子进程提供方与类型化 Remote 传输管理用户终端进程、解析默认 shell,并恢复有界终端屏幕。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index f95d78fd84..88c19f5c80 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 6b5a03e9f5f59ff987093a94334b00b0d81b2c9d -config-catalog.zh.md: 6eb6c566b87ad54a886b272b18f13473b8b2ec63 +config-catalog.md: 43e2e391473d98d0bd1a5902e30b44d306b86c9a +config-catalog.zh.md: f2b1230d7ac4d269754419a2cde3f1274c073d93 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6b5a03e9f5..43e2e39147 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3590,22 +3590,18 @@ Source: [`packages/workflow/workflow-ptc/src/index.ts:32`](../packages/workflow/ Requires: `subprocess` ```ts config-catalog -/** Snapshot bounds and object store placement. Invalid values fail plugin load. */ +/** Snapshot bounds. Invalid values fail plugin load. */ export interface Config { - /** Harness home whose `workspace-changes/` directory holds one snapshot object store per repository; `$DSH_HOME`, then `~/.dsh`. */ - dshHome?: string - /** Bytes one repository's snapshot object store may hold before it is discarded and restarted empty. */ - objectStoreMaxBytes: number /** Milliseconds one git command may run before the turn's record is abandoned. */ timeoutMs: number /** Bytes of git output retained per command; a larger diff listing abandons the record. */ outputMaxBytes: number - /** Maximum files carried by one event; `total` still reports the complete count. */ + /** Maximum files carried by one summary; `total` still reports the complete count. */ maxFiles: number } ``` -Source: [`packages/deliverables/workspace-changes/src/index.ts:28`](../packages/deliverables/workspace-changes/src/index.ts) +Source: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts) ## Loadable plugins with no config @@ -3629,7 +3625,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 6eb6c566b8..f2b1230d7a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3592,22 +3592,18 @@ export interface Config { 依赖: `subprocess` ```ts config-catalog -/** Snapshot bounds and object store placement. Invalid values fail plugin load. */ +/** Snapshot bounds. Invalid values fail plugin load. */ export interface Config { - /** Harness home whose `workspace-changes/` directory holds one snapshot object store per repository; `$DSH_HOME`, then `~/.dsh`. */ - dshHome?: string - /** Bytes one repository's snapshot object store may hold before it is discarded and restarted empty. */ - objectStoreMaxBytes: number /** Milliseconds one git command may run before the turn's record is abandoned. */ timeoutMs: number /** Bytes of git output retained per command; a larger diff listing abandons the record. */ outputMaxBytes: number - /** Maximum files carried by one event; `total` still reports the complete count. */ + /** Maximum files carried by one summary; `total` still reports the complete count. */ maxFiles: number } ``` -来源: [`packages/deliverables/workspace-changes/src/index.ts:28`](../packages/deliverables/workspace-changes/src/index.ts) +来源: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts) ## 无配置的可加载插件 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a185cda2e0..2ba4f12726 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: e5c37f2ac69c89f64b796d1754cc00643075860b -module-graph.zh.md: 84236800283a184c12fe9cb30feef2934d07f030 +module-graph.md: 677e090d145577acf1e085c2bad951988882e366 +module-graph.zh.md: 803296c238570d96f907c0f99a8d521b320ea05a diff --git a/docs/module-graph.md b/docs/module-graph.md index e5c37f2ac6..677e090d14 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -818,7 +818,6 @@ flowchart TD pkg_tool_present --> pkg_session_projection pkg_tool_present --> pkg_tools pkg_workspace_changes --> pkg_agent - pkg_workspace_changes --> pkg_home_paths pkg_workspace_changes --> pkg_session pkg_workspace_changes --> pkg_subprocess pkg_workspace_changes --> pkg_tools @@ -1493,7 +1492,7 @@ flowchart TD | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`home-paths`](../packages/util/home-paths), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8423680028..803296c238 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -820,7 +820,6 @@ flowchart TD pkg_tool_present --> pkg_session_projection pkg_tool_present --> pkg_tools pkg_workspace_changes --> pkg_agent - pkg_workspace_changes --> pkg_home_paths pkg_workspace_changes --> pkg_session pkg_workspace_changes --> pkg_subprocess pkg_workspace_changes --> pkg_tools @@ -1495,7 +1494,7 @@ flowchart TD | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`home-paths`](../packages/util/home-paths), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index c0cd08d5b7..b8e78962af 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: 3011a1ec612f0e77925793e07ab07a567eb78b40 -persistence-catalog.zh.md: c1ca737cf0e1eba772b14dd00c93121b25e1339c +persistence-catalog.md: 29e5d0a42cf92d0153f7db6911c5ae4ba3175f2f +persistence-catalog.zh.md: fc06d73f18e31333f540ba8630f5029eaeb46b32 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3011a1ec61..29e5d0a42c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -77,7 +77,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`event:turn/start`](#persistence-type-eventturnstart) | | `event:user/message` | event | `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | [`event:user/message`](#persistence-type-eventusermessage) | | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`event:web/deepseek-search-llm-request`](#persistence-type-eventwebdeepseek-search-llm-request) | -| `event:workspace/changes` | event | `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) | +| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) | ## Event envelope @@ -1248,13 +1248,15 @@ Source: [`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/ #### `workspace/changes` — log-only ```ts persistence-catalog -/** Files changed by a completed top-level turn; the latest event for one turn replaces earlier ones. */ -'workspace/changes': WorkspaceChangesData +/** + * A completed top-level turn's changed files were summarized; the summary itself stays on the + * Host and is served by `workspaceChanges.summary` for the event's sequence while the Session + * lives. The latest event for one turn replaces earlier ones. + */ +'workspace/changes': { turn: number } ``` -Types: [WorkspaceChangesData](subsystems/deliverables.md) - -Source: [`packages/deliverables/workspace-changes/src/types.ts:36`](../packages/deliverables/workspace-changes/src/types.ts) +Source: [`packages/deliverables/workspace-changes/src/types.ts:52`](../packages/deliverables/workspace-changes/src/types.ts) ## Resolved persistence types @@ -3918,7 +3920,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:52`](../packages/deliverables/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4043,37 +4045,16 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:71`](../packages/web ### `event:workspace/changes` -SHA-256: `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` +SHA-256: `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | Property | Presence | Type | |---|---|---| -| `data` | required | [`packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData`](#persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangesdata) | +| `data` | required | [`event:turn/start.data`](#persistence-type-eventturnstartdata) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | | `type` | required | `"workspace/changes"` | - - -### `event:workspace/changes.data.files` - -SHA-256: `39c5a9c9f4688d412c567d287ae6577a3f38a73c3f8ba4deffc563d9ec93b927` - -Array of [`packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile`](#persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangedfile). - - - -### `event:workspace/changes.data.snapshot` - -SHA-256: `f53ef94c56c9b1b890a1eba459ca22023d91f5f50bc829289181961ee052e4f7` - -Sources: [`packages/deliverables/workspace-changes/src/types.ts:30`](../packages/deliverables/workspace-changes/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `after` | required | `string` | -| `before` | required | `string` | - ### `"every"` @@ -4734,37 +4715,6 @@ Sources: [`packages/deliverables/tool-present/src/types.ts:5`](../packages/deliv | `description` | optional | `string` | | `path` | required | `string` | - - -### `packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile` - -SHA-256: `14b37f44c06d8820b6601845f2ad7c995522044c58a6e87da04e29718f4835a1` - -Sources: [`packages/deliverables/workspace-changes/src/types.ts:4`](../packages/deliverables/workspace-changes/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `added` | required | `number` | -| `binary` | optional | `true` | -| `deleted` | required | `number` | -| `display` | required | `string` | -| `path` | required | `string` | - - - -### `packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData` - -SHA-256: `69ef2dbc5ed9134fd8199bf6da90da4bd78de61e9336ef008d1770101aa40ae2` - -Sources: [`packages/deliverables/workspace-changes/src/types.ts:22`](../packages/deliverables/workspace-changes/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `files` | required | [`event:workspace/changes.data.files`](#persistence-type-eventworkspacechangesdatafiles) | -| `snapshot` | required | [`event:workspace/changes.data.snapshot`](#persistence-type-eventworkspacechangesdatasnapshot) | -| `total` | required | `number` | -| `turn` | required | `number` | - ### `packages/experimental/agent-team/src/types.ts#TeamMemberPhase` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c1ca737cf0..fc06d73f18 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -79,7 +79,7 @@ | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`event:turn/start`](#persistence-type-eventturnstart) | | `event:user/message` | event | `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | [`event:user/message`](#persistence-type-eventusermessage) | | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`event:web/deepseek-search-llm-request`](#persistence-type-eventwebdeepseek-search-llm-request) | -| `event:workspace/changes` | event | `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) | +| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`event:workspace/changes`](#persistence-type-eventworkspacechanges) | ## 事件信封 @@ -1250,13 +1250,15 @@ export type SessionEvent = { #### `workspace/changes` — log-only ```ts persistence-catalog -/** Files changed by a completed top-level turn; the latest event for one turn replaces earlier ones. */ -'workspace/changes': WorkspaceChangesData +/** + * A completed top-level turn's changed files were summarized; the summary itself stays on the + * Host and is served by `workspaceChanges.summary` for the event's sequence while the Session + * lives. The latest event for one turn replaces earlier ones. + */ +'workspace/changes': { turn: number } ``` -类型:[WorkspaceChangesData](subsystems/deliverables.zh.md) - -来源:[`packages/deliverables/workspace-changes/src/types.ts:36`](../packages/deliverables/workspace-changes/src/types.ts) +来源:[`packages/deliverables/workspace-changes/src/types.ts:52`](../packages/deliverables/workspace-changes/src/types.ts) ## 已解析的持久化类型 @@ -3920,7 +3922,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:52`](../packages/deliverables/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4045,37 +4047,16 @@ SHA-256: `2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3` ### `event:workspace/changes` -SHA-256: `0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061` +SHA-256: `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData`](#persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangesdata) | +| `data` | 必需 | [`event:turn/start.data`](#persistence-type-eventturnstartdata) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | | `type` | 必需 | `"workspace/changes"` | - - -### `event:workspace/changes.data.files` - -SHA-256: `39c5a9c9f4688d412c567d287ae6577a3f38a73c3f8ba4deffc563d9ec93b927` - -[`packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile`](#persistence-type-packagesdeliverablesworkspace-changessrctypestsworkspacechangedfile) 的数组。 - - - -### `event:workspace/changes.data.snapshot` - -SHA-256: `f53ef94c56c9b1b890a1eba459ca22023d91f5f50bc829289181961ee052e4f7` - -来源:[`packages/deliverables/workspace-changes/src/types.ts:30`](../packages/deliverables/workspace-changes/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `after` | 必需 | `string` | -| `before` | 必需 | `string` | - ### `"every"` @@ -4736,37 +4717,6 @@ SHA-256: `b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97` | `description` | 可选 | `string` | | `path` | 必需 | `string` | - - -### `packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile` - -SHA-256: `14b37f44c06d8820b6601845f2ad7c995522044c58a6e87da04e29718f4835a1` - -来源:[`packages/deliverables/workspace-changes/src/types.ts:4`](../packages/deliverables/workspace-changes/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `added` | 必需 | `number` | -| `binary` | 可选 | `true` | -| `deleted` | 必需 | `number` | -| `display` | 必需 | `string` | -| `path` | 必需 | `string` | - - - -### `packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData` - -SHA-256: `69ef2dbc5ed9134fd8199bf6da90da4bd78de61e9336ef008d1770101aa40ae2` - -来源:[`packages/deliverables/workspace-changes/src/types.ts:22`](../packages/deliverables/workspace-changes/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `files` | 必需 | [`event:workspace/changes.data.files`](#persistence-type-eventworkspacechangesdatafiles) | -| `snapshot` | 必需 | [`event:workspace/changes.data.snapshot`](#persistence-type-eventworkspacechangesdatasnapshot) | -| `total` | 必需 | `number` | -| `turn` | 必需 | `number` | - ### `packages/experimental/agent-team/src/types.ts#TeamMemberPhase` diff --git a/docs/persistence-changes/2026-09-14-workspace-changes-event.i18n.yaml b/docs/persistence-changes/2026-09-14-workspace-changes-event.i18n.yaml index 7d00679c75..f640f5e25b 100644 --- a/docs/persistence-changes/2026-09-14-workspace-changes-event.i18n.yaml +++ b/docs/persistence-changes/2026-09-14-workspace-changes-event.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/persistence-changes/2026-09-14-workspace-changes-event.md -2026-09-14-workspace-changes-event.md: e204be8e8b3dbad37bee2b9aa428b8cfa1664d74 -2026-09-14-workspace-changes-event.zh.md: 7f7b92a2e0e91e27944f0b858edfc5e8a3a2314e +2026-09-14-workspace-changes-event.md: 5d8a4ff6526dc67444d5dce0076b005d96b3b059 +2026-09-14-workspace-changes-event.zh.md: fa2d037dc50dae2bfb7ba4b3801558559b73fd7c diff --git a/docs/persistence-changes/2026-09-14-workspace-changes-event.md b/docs/persistence-changes/2026-09-14-workspace-changes-event.md index e204be8e8b..5d8a4ff652 100644 --- a/docs/persistence-changes/2026-09-14-workspace-changes-event.md +++ b/docs/persistence-changes/2026-09-14-workspace-changes-event.md @@ -28,7 +28,7 @@ baseline: false changes: - root: "event:workspace/changes" previous: null - after: "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061" + after: "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72" decision: same-version ``` diff --git a/docs/persistence-changes/2026-09-14-workspace-changes-event.schema.json b/docs/persistence-changes/2026-09-14-workspace-changes-event.schema.json index 412d1feb08..befaf7f1ec 100644 --- a/docs/persistence-changes/2026-09-14-workspace-changes-event.schema.json +++ b/docs/persistence-changes/2026-09-14-workspace-changes-event.schema.json @@ -6,7 +6,7 @@ "kind": "event", "event": "workspace/changes", "surface": false, - "digest": "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061", + "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72", "schema": { "root": 0, "nodes": [ @@ -20,83 +20,33 @@ }, { "name": "ignorable", - "type": 5, + "type": 3, "optional": true }, { "name": "seq", - "type": 4, - "optional": false - }, - { - "name": "time", - "type": 4, - "optional": false - }, - { - "name": "type", - "type": 8, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "files", "type": 2, "optional": false }, { - "name": "snapshot", - "type": 7, + "name": "time", + "type": 2, "optional": false }, { - "name": "total", - "type": 4, - "optional": false - }, - { - "name": "turn", + "name": "type", "type": 4, "optional": false } ], "indices": [] }, - { - "kind": "array", - "element": 3 - }, { "kind": "object", "properties": [ { - "name": "added", - "type": 4, - "optional": false - }, - { - "name": "binary", - "type": 5, - "optional": true - }, - { - "name": "deleted", - "type": 4, - "optional": false - }, - { - "name": "display", - "type": 6, - "optional": false - }, - { - "name": "path", - "type": 6, + "name": "turn", + "type": 2, "optional": false } ], @@ -110,26 +60,6 @@ "kind": "literal", "value": true }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "object", - "properties": [ - { - "name": "after", - "type": 6, - "optional": false - }, - { - "name": "before", - "type": 6, - "optional": false - } - ], - "indices": [] - }, { "kind": "literal", "value": "workspace/changes" diff --git a/docs/persistence-changes/2026-09-14-workspace-changes-event.zh.md b/docs/persistence-changes/2026-09-14-workspace-changes-event.zh.md index 7f7b92a2e0..fa2d037dc5 100644 --- a/docs/persistence-changes/2026-09-14-workspace-changes-event.zh.md +++ b/docs/persistence-changes/2026-09-14-workspace-changes-event.zh.md @@ -28,7 +28,7 @@ baseline: false changes: - root: "event:workspace/changes" previous: null - after: "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061" + after: "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72" decision: same-version ``` diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 31ed224448..b60b927322 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/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 docs/persistence-changes/historical-formats/README.md -README.md: 24b85073077fd85cf2d9e140fb959cddf192467c -README.zh.md: 50edb73cf7f888626719a98f2fcecaa45528eac5 +README.md: fda6ac3b4fc3e52ef215dc0a2fca00576cd4ef0c +README.zh.md: 792f47e532d6ebfdbb745c1bc2703c7b5f4fcc17 diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 24b8507307..fda6ac3b4f 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -31,7 +31,7 @@ The index is generated from validated snapshots and the current writer constant. | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.md) | [JSON](v0.schema.json) | 54 / 415 | | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | -| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 61 / 474 | +| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 61 / 470 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 50edb73cf7..792f47e532 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -31,7 +31,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.zh.md) | [JSON](v0.schema.json) | 54 / 415 | | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | -| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 61 / 474 | +| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 61 / 470 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 3c7d3e4bb4..7c5cc4b8ee 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -13748,7 +13748,7 @@ "kind": "event", "event": "workspace/changes", "surface": false, - "digest": "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061", + "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72", "schema": { "root": 0, "nodes": [ @@ -13762,83 +13762,33 @@ }, { "name": "ignorable", - "type": 5, + "type": 3, "optional": true }, { "name": "seq", - "type": 4, - "optional": false - }, - { - "name": "time", - "type": 4, - "optional": false - }, - { - "name": "type", - "type": 8, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "files", "type": 2, "optional": false }, { - "name": "snapshot", - "type": 7, + "name": "time", + "type": 2, "optional": false }, { - "name": "total", - "type": 4, - "optional": false - }, - { - "name": "turn", + "name": "type", "type": 4, "optional": false } ], "indices": [] }, - { - "kind": "array", - "element": 3 - }, { "kind": "object", "properties": [ { - "name": "added", - "type": 4, - "optional": false - }, - { - "name": "binary", - "type": 5, - "optional": true - }, - { - "name": "deleted", - "type": 4, - "optional": false - }, - { - "name": "display", - "type": 6, - "optional": false - }, - { - "name": "path", - "type": 6, + "name": "turn", + "type": 2, "optional": false } ], @@ -13852,26 +13802,6 @@ "kind": "literal", "value": true }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "object", - "properties": [ - { - "name": "after", - "type": 6, - "optional": false - }, - { - "name": "before", - "type": 6, - "optional": false - } - ], - "indices": [] - }, { "kind": "literal", "value": "workspace/changes" @@ -14722,142 +14652,6 @@ "names": [], "sources": [] }, - { - "digest": "0a176529351301e438fc4d94ea0c80f0f105e33fd85a256d76178b0a6dda3061", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 5, - "optional": true - }, - { - "name": "seq", - "type": 4, - "optional": false - }, - { - "name": "time", - "type": 4, - "optional": false - }, - { - "name": "type", - "type": 8, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "files", - "type": 2, - "optional": false - }, - { - "name": "snapshot", - "type": 7, - "optional": false - }, - { - "name": "total", - "type": 4, - "optional": false - }, - { - "name": "turn", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "array", - "element": 3 - }, - { - "kind": "object", - "properties": [ - { - "name": "added", - "type": 4, - "optional": false - }, - { - "name": "binary", - "type": 5, - "optional": true - }, - { - "name": "deleted", - "type": 4, - "optional": false - }, - { - "name": "display", - "type": 6, - "optional": false - }, - { - "name": "path", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "object", - "properties": [ - { - "name": "after", - "type": 6, - "optional": false - }, - { - "name": "before", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "workspace/changes" - } - ] - }, - "names": [ - "event:workspace/changes" - ], - "sources": [] - }, { "digest": "0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3", "schema": { @@ -18045,63 +17839,6 @@ "names": [], "sources": [] }, - { - "digest": "14b37f44c06d8820b6601845f2ad7c995522044c58a6e87da04e29718f4835a1", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "added", - "type": 1, - "optional": false - }, - { - "name": "binary", - "type": 2, - "optional": true - }, - { - "name": "deleted", - "type": 1, - "optional": false - }, - { - "name": "display", - "type": 3, - "optional": false - }, - { - "name": "path", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "primitive", - "type": "string" - } - ] - }, - "names": [ - "packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangedFile" - ], - "sources": [ - "packages/deliverables/workspace-changes/src/types.ts:4" - ] - }, { "digest": "15196447222782e773eb943c92b18316ce96b9af0f0cfddb6e57ba8274ecc5ff", "schema": { @@ -28324,65 +28061,6 @@ "packages/feedback/message-feedback/src/types.ts:17" ] }, - { - "digest": "39c5a9c9f4688d412c567d287ae6577a3f38a73c3f8ba4deffc563d9ec93b927", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "array", - "element": 1 - }, - { - "kind": "object", - "properties": [ - { - "name": "added", - "type": 2, - "optional": false - }, - { - "name": "binary", - "type": 3, - "optional": true - }, - { - "name": "deleted", - "type": 2, - "optional": false - }, - { - "name": "display", - "type": 4, - "optional": false - }, - { - "name": "path", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "primitive", - "type": "string" - } - ] - }, - "names": [ - "event:workspace/changes.data.files" - ], - "sources": [] - }, { "digest": "3aa261c8516b5c955ed12080e6257a56cbe513f6c9f0ac9274c12e96c196ebca", "schema": { @@ -38329,109 +38007,6 @@ "names": [], "sources": [] }, - { - "digest": "69ef2dbc5ed9134fd8199bf6da90da4bd78de61e9336ef008d1770101aa40ae2", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "files", - "type": 1, - "optional": false - }, - { - "name": "snapshot", - "type": 6, - "optional": false - }, - { - "name": "total", - "type": 3, - "optional": false - }, - { - "name": "turn", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "array", - "element": 2 - }, - { - "kind": "object", - "properties": [ - { - "name": "added", - "type": 3, - "optional": false - }, - { - "name": "binary", - "type": 4, - "optional": true - }, - { - "name": "deleted", - "type": 3, - "optional": false - }, - { - "name": "display", - "type": 5, - "optional": false - }, - { - "name": "path", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "object", - "properties": [ - { - "name": "after", - "type": 5, - "optional": false - }, - { - "name": "before", - "type": 5, - "optional": false - } - ], - "indices": [] - } - ] - }, - "names": [ - "packages/deliverables/workspace-changes/src/types.ts#WorkspaceChangesData" - ], - "sources": [ - "packages/deliverables/workspace-changes/src/types.ts:22" - ] - }, { "digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7", "schema": { @@ -58854,6 +58429,72 @@ "names": [], "sources": [] }, + { + "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 3, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "turn", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "workspace/changes" + } + ] + }, + "names": [ + "event:workspace/changes" + ], + "sources": [] + }, { "digest": "e31572180d6b28b73f073ad30ae19d5d82982d3daf984723984e23da1e7c459a", "schema": { @@ -60317,40 +59958,6 @@ "packages/llm/llm/src/types.ts:381" ] }, - { - "digest": "f53ef94c56c9b1b890a1eba459ca22023d91f5f50bc829289181961ee052e4f7", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "after", - "type": 1, - "optional": false - }, - { - "name": "before", - "type": 1, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "string" - } - ] - }, - "names": [ - "event:workspace/changes.data.snapshot" - ], - "sources": [ - "packages/deliverables/workspace-changes/src/types.ts:30" - ] - }, { "digest": "f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f", "schema": { @@ -61086,10 +60693,12 @@ ] }, "names": [ - "event:turn/start.data" + "event:turn/start.data", + "event:workspace/changes.data" ], "sources": [ - "packages/core/session/src/types.ts:276" + "packages/core/session/src/types.ts:276", + "packages/deliverables/workspace-changes/src/types.ts:52" ] }, { diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 2894e97d31..28e44ea409 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/README.md -README.md: 9ee98bfa5f0d468cde8e11dad22567b05de9e41f -README.zh.md: 22e3cd76801354228d00742a2698929e319948cb +README.md: a1be9608cae9331e5b97f1215bf10c40dcb2e5e7 +README.zh.md: c57d125d3f8b19c358cbf16f6bc701dfc103edb6 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 9ee98bfa5f..a1be9608ca 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -14,7 +14,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | | [schedule.md](schedule.md) | Session-local reminder records, durable transitions, active views, and ordinary-conversation delivery | | [todo.md](todo.md) | the todo package's whole-list item type, durable event ownership, projection, and open-turn invariant | -| [deliverables.md](deliverables.md) | what a turn hands to the user: `PresentedFile` deliveries from `present` and the `WorkspaceChangesData` summary of changed files from git snapshots | +| [deliverables.md](deliverables.md) | what a turn hands to the user: `PresentedFile` deliveries from `present` and the Host-served `WorkspaceChangesSummary` of changed files from git snapshots | | [commands.md](commands.md) | the human-command registry service: definitions, adapter discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, the JSONL provider, `session/flush`, crash recovery, `SessionHeader` | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 22e3cd7680..c57d125d3f 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -14,7 +14,7 @@ | [goal.md](goal.zh.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [schedule.md](schedule.zh.md) | 仅限会话内的提醒记录、持久转换、活动视图与普通对话交付 | | [todo.md](todo.zh.md) | todo 包的整列表条目类型、持久事件所有权、投影和未结束轮次不变式 | -| [deliverables.md](deliverables.zh.md) | 一轮交给用户的东西:`present` 声明的 `PresentedFile` 交付,以及由 git 快照得出的 `WorkspaceChangesData` 改动摘要 | +| [deliverables.md](deliverables.zh.md) | 一轮交给用户的东西:`present` 声明的 `PresentedFile` 交付,以及由 git 快照得出、由 Host 提供的 `WorkspaceChangesSummary` 改动摘要 | | [commands.md](commands.zh.md) | 人类命令注册表服务:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.zh.md) | 完整的 `SessionEventMap` 变体目录、`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.zh.md) | 持久性 seam:`SessionPersistence`、JSONL 提供方、`session/flush`、崩溃恢复、`SessionHeader` | diff --git a/docs/subsystems/deliverables.i18n.yaml b/docs/subsystems/deliverables.i18n.yaml index 9975a4c5d3..3ff1ae458a 100644 --- a/docs/subsystems/deliverables.i18n.yaml +++ b/docs/subsystems/deliverables.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/subsystems/deliverables.md -deliverables.md: f8e2b34d0f57537a8cc2e97331f1bf04525ddb4e -deliverables.zh.md: da8d7a93d282ab4a0bd02f5e5eadcd4a0f75703b +deliverables.md: 22b51978fddc44bc40083bcbd501aec19a380937 +deliverables.zh.md: 67774eb4ba8c91e030ef20e228557276ff73d48b diff --git a/docs/subsystems/deliverables.md b/docs/subsystems/deliverables.md index f8e2b34d0f..22b51978fd 100644 --- a/docs/subsystems/deliverables.md +++ b/docs/subsystems/deliverables.md @@ -2,7 +2,7 @@ English | [中文](deliverables.zh.md) -What a turn hands to the user, recorded as two log-only Session events owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end. Only clients read the events; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md). +What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end, announced by a log-only event and served by a Host service while the Session lives. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md). Sources: [`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts), [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) @@ -40,22 +40,66 @@ interface WorkspaceChangedFile { } ``` -## `WorkspaceChangesData` — one turn's change summary +## `WorkspaceChangesSummary` — one turn's change summary ```ts type-equiv -/** Files changed during one top-level turn. */ -interface WorkspaceChangesData { +/** Files changed during one top-level turn, kept on the Host until its Session is disposed. */ +interface WorkspaceChangesSummary { /** The turn whose file changes this summary describes. */ turn: number + /** The Session working directory `path` values are relative to. */ + cwd: string /** Changed files in `display` order, capped at the plugin's `maxFiles`. */ files: WorkspaceChangedFile[] /** Complete changed-file count, including files omitted by the cap. */ total: number - /** Git tree object ids of the turn-start and turn-end working-tree snapshots. */ - snapshot: { before: string; after: string } } ``` -## Durable events +## `WorkspaceChanges` — the Host service serving summaries -`tool-present` declaration-merges `deliverables/presented: { turn; callId; files: PresentedFile[] }` into `SessionEventMap`, appended once per successful final `present` result. `workspace-changes` merges `workspace/changes: WorkspaceChangesData`, appended when a top-level turn stops; a later event for the same turn replaces the earlier one, so a client keeps only the latest. The generated [persistence catalog](../persistence-catalog.md#deliverablespresented--log-only) records both declaration sites. Neither event reaches the model. +```ts type-equiv +/** Serves the summaries the recorder keeps for live Sessions. */ +interface WorkspaceChanges { + /** + * The summary announced by one `workspace/changes` event. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. + */ + summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined +} +``` + +## Durable events and the served summary + +`tool-present` declaration-merges `deliverables/presented: { turn; callId; files: PresentedFile[] }` into `SessionEventMap`, appended once per successful final `present` result. `workspace-changes` merges `workspace/changes: { turn }`, appended when a top-level turn stops; the summary that event announced is not in the log but is returned by `workspaceChanges.summary(sessionId, seq)` for the event's sequence until the Session is disposed, so a conversation reopened after a Host restart has no changed-files card for its earlier turns. A later event for the same turn replaces the earlier one, so a client keeps only the latest. The generated [persistence catalog](../persistence-catalog.md#deliverablespresented--log-only) records both declaration sites. Neither event reaches the model. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.workspaceChanges` — `WorkspaceChanges` + +Serves the summaries the recorder keeps for live Sessions. + +```ts cordis-catalog +/** + * The summary announced by one `workspace/changes` event. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. + */ +summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined +``` + +Types: [SessionId](core.md) + +Source: [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) + diff --git a/docs/subsystems/deliverables.zh.md b/docs/subsystems/deliverables.zh.md index da8d7a93d2..67774eb4ba 100644 --- a/docs/subsystems/deliverables.zh.md +++ b/docs/subsystems/deliverables.zh.md @@ -2,7 +2,7 @@ [English](deliverables.md) | 中文 -记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有的两个只写日志的 Session 事件承载:模型通过 `present` 工具声明的文件,以及这一轮改动的文件,后者由轮开始和轮结束时的 git 工作树快照对比得出。事件只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。 +记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出,用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。 源码:[`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts)、[`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) @@ -40,22 +40,66 @@ interface WorkspaceChangedFile { } ``` -## `WorkspaceChangesData`:一轮的改动摘要 +## `WorkspaceChangesSummary`:一轮的改动摘要 ```ts type-equiv -/** Files changed during one top-level turn. */ -interface WorkspaceChangesData { +/** Files changed during one top-level turn, kept on the Host until its Session is disposed. */ +interface WorkspaceChangesSummary { /** The turn whose file changes this summary describes. */ turn: number + /** The Session working directory `path` values are relative to. */ + cwd: string /** Changed files in `display` order, capped at the plugin's `maxFiles`. */ files: WorkspaceChangedFile[] /** Complete changed-file count, including files omitted by the cap. */ total: number - /** Git tree object ids of the turn-start and turn-end working-tree snapshots. */ - snapshot: { before: string; after: string } } ``` -## 持久事件 +## `WorkspaceChanges`:提供摘要的 Host 服务 -`tool-present` 通过声明合并把 `deliverables/presented: { turn; callId; files: PresentedFile[] }` 加入 `SessionEventMap`,每次 `present` 的最终结果成功时追加一条。`workspace-changes` 合并 `workspace/changes: WorkspaceChangesData`,在顶层轮停止时追加;同一轮后来的事件替代先前的,客户端只保留最新一条。生成的[持久化目录](../persistence-catalog.zh.md#deliverablespresented--log-only)记录了两处声明位置。两个事件都不会进入模型请求。 +```ts type-equiv +/** Serves the summaries the recorder keeps for live Sessions. */ +interface WorkspaceChanges { + /** + * The summary announced by one `workspace/changes` event. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. + */ + summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined +} +``` + +## 持久事件与提供的摘要 + +`tool-present` 通过声明合并把 `deliverables/presented: { turn; callId; files: PresentedFile[] }` 加入 `SessionEventMap`,每次 `present` 的最终结果成功时追加一条。`workspace-changes` 合并 `workspace/changes: { turn }`,在顶层轮停止时追加;该事件宣告的摘要不在日志里,而是由 `workspaceChanges.summary(sessionId, seq)` 按事件序号返回,直到 Session 释放,因此 Host 重启后重新打开的对话,先前轮次没有改动文件卡片。同一轮后来的事件替代先前的,客户端只保留最新一条。生成的[持久化目录](../persistence-catalog.zh.md#deliverablespresented--log-only)记录了两处声明位置。两个事件都不会进入模型请求。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.workspaceChanges` — `WorkspaceChanges` + +Serves the summaries the recorder keeps for live Sessions. + +```ts cordis-catalog +/** + * The summary announced by one `workspace/changes` event. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. + */ +summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined +``` + +Types: [SessionId](core.zh.md) + +Source: [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) + diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 1c244b570d..27b3e64560 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/ui-deliverables/README.md -README.md: 89f9e683d18928b449b6336fabf1909284f7e371 -README.zh.md: cbfc98a428e4037ec1c65523c6574b1e42af94da +README.md: 340a4c8a0dc6e3d99e71973b5ecfe13560716422 +README.zh.md: 4decf7f247fe33c5b8d4ed92f2f05925e3897df0 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 89f9e683d1..340a4c8a0d 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -25,7 +25,7 @@ This package renders the changed-files card a finished turn ends with — the fi ## Use this package -Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../deliverables/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a recorded summary — a workspace outside any git repository, no git on the Host, or the plugin composed out — the card is absent and only deliveries and prose links remain. +Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../deliverables/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a served summary — a workspace outside any git repository, no git on the Host, the plugin composed out, or a Host restarted since the turn ran — the card is absent and only deliveries and prose links remain. ### Explicit deliveries @@ -36,7 +36,7 @@ The `present` tool row shows running, delivered, failed, or interrupted status; ### The changed-files card -The card renders the turn's latest `workspace/changes` event: its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, or “binary” for a binary file. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager and each row opens its file in the default application, both through authenticated Host actions that show the delivery cards' pending and retryable-error states in place of the counts; a completed open shows the counts again. Without a desktop the header is a plain label and rows preview their files in the right Sidebar. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. +The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, or “binary” for a binary file. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager and each row opens its file in the default application, both through authenticated Host actions that show the delivery cards' pending and retryable-error states in place of the counts; a completed open shows the counts again. Without a desktop the header is a plain label and rows preview their files in the right Sidebar. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. ### Inline-code links @@ -50,7 +50,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv
      Implementation internals — click to expand -The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's latest validated `workspace/changes` event into `DeliverablesTurnData.changes`, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. +The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory. The Host reads the viewed Session header with the declaration or summary and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. @@ -63,7 +63,7 @@ Native opening uses an authenticated POST addressed by the viewed Session, event Read these pages when the deliverables surface is not enough. They move from the card to the Host recorder, the turn-tail hole, and the decisions behind the vocabulary. -- [workspace-changes](../../deliverables/workspace-changes/README.md) — the Host plugin that records the summary the card renders. +- [workspace-changes](../../deliverables/workspace-changes/README.md) — the Host plugin that records and serves the summary the card renders. - [ui-conversation](../ui-conversation/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose. - [Turn changed-files card](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the decision behind git-recorded summaries replacing the mutation-call row. - [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the earlier produced-files row; its Host open path is superseded by the [right Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md). diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index cbfc98a428..4decf7f247 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../deliverables/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有记录的摘要时——工作区不在任何 git 仓库内、Host 上没有 git,或该插件被组合出去——卡片不出现,只保留交付卡片与正文链接。 +与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../deliverables/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有可提供的摘要时——工作区不在任何 git 仓库内、Host 上没有 git、该插件被组合出去,或该轮之后 Host 重启过——卡片不出现,只保留交付卡片与正文链接。 ### 显式交付 @@ -36,7 +36,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 ### 改动文件卡片 -卡片渲染本轮最新的 `workspace/changes` 事件:标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。Host 有桌面时,标题在文件管理器中打开包含所列文件的最深工作区文件夹,每一行用默认应用打开该文件,两者都通过经过认证的 Host 操作完成,并在计数位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示计数。没有桌面时标题是普通标签,行改为在右侧 Sidebar 中预览文件。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 +卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。Host 有桌面时,标题在文件管理器中打开包含所列文件的最深工作区文件夹,每一行用默认应用打开该文件,两者都通过经过认证的 Host 操作完成,并在计数位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示计数。没有桌面时标题是普通标签,行改为在右侧 Sidebar 中预览文件。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 ### 行内代码链接 @@ -50,7 +50,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
      实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 事件折叠进 `DeliverablesTurnData.changes`,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 +Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录。Host 读取声明或摘要及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 @@ -63,7 +63,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 当产出物面不够用时阅读以下页面。它们从卡片进入 Host 记录器、turn-tail 洞与词表背后的决策。 -- [workspace-changes](../../deliverables/workspace-changes/README.zh.md)——记录卡片所渲染摘要的 Host 插件。 +- [workspace-changes](../../deliverables/workspace-changes/README.zh.md)——记录并提供卡片所渲染摘要的 Host 插件。 - [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。 - [本轮改动文件卡片](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——用 git 记录的摘要取代修改调用行背后的决策。 - [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——早先产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。 diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts index b86bee4915..9a9c3aa0c0 100644 --- a/packages/client/ui-deliverables/src/changes.ts +++ b/packages/client/ui-deliverables/src/changes.ts @@ -1,39 +1,68 @@ -/** Validate recorded workspace changes and address their native-open actions. */ +/** Validate workspace-change records that cross the Host route and address their summary and native-open actions. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { WorkspaceChangedFile, WorkspaceChangesData } from '@deepseek-ai/dsh-workspace-changes/types' +import type { WorkspaceChangedFile, WorkspaceChangesSummary } from '@deepseek-ai/dsh-workspace-changes/types' + +/** Authenticated GET route serving one announced change summary while its Session lives. */ +export const CHANGED_FILES_PATH = '/api/changes.summary' /** Authenticated POST route for opening a changed file, or the changed files' common folder, on the Host desktop. */ export const CHANGES_OPEN_PATH = '/api/changes.open' +/** The summary fields the route serves; the Host keeps the working directory to itself. */ +export type ChangesSummary = Pick + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + /** - * Validate one changed-file record read from a Session log. - * @param value - decoded durable data. + * Validate one changed-file record read from the summary route. + * @param value - decoded JSON. * @returns whether the record carries a path, a display path, and line counts. */ export function isChangedFile(value: unknown): value is WorkspaceChangedFile { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false - const { path, display, added, deleted, binary } = value as Record + if (!isRecord(value)) return false + const { path, display, added, deleted, binary } = value return typeof path === 'string' && path.length > 0 && typeof display === 'string' && display.length > 0 && Number.isSafeInteger(added) && Number.isSafeInteger(deleted) && (binary === undefined || binary === true) } /** - * Validate a change summary before reading its turn or files. - * @param value - decoded durable event data. - * @returns whether the event identifies a turn, a complete file list, and the total count. + * Validate a summary read from the summary route. + * @param value - decoded JSON. + * @returns whether the value identifies a turn, a complete file list, and the total count. */ -export function isChangesData(value: unknown): value is WorkspaceChangesData { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false - const { turn, files, total } = value as Record - return typeof turn === 'number' && Number.isSafeInteger(turn) && turn >= 1 - && Number.isSafeInteger(total) && Array.isArray(files) && files.every(isChangedFile) +export function isChangesSummary(value: unknown): value is ChangesSummary { + if (!isRecord(value)) return false + const { turn, files, total } = value + return Number.isSafeInteger(turn) && (turn as number) >= 1 && Number.isSafeInteger(total) + && Array.isArray(files) && files.every(isChangedFile) +} + +/** + * Validate the `workspace/changes` event data read from a Session log. + * @param value - decoded durable event data. + * @returns whether the event names a turn. + */ +export function isChangesEvent(value: unknown): value is { turn: number } { + return isRecord(value) && Number.isSafeInteger(value.turn) && (value.turn as number) >= 1 +} + +/** + * Build authenticated coordinates for the summary one `workspace/changes` event announced. + * @param sessionId - owning Session. + * @param seq - event sequence. + * @returns same-origin summary URL. + */ +export function changesSummaryUrl(sessionId: SessionId, seq: number): string { + return `${CHANGED_FILES_PATH}?${new URLSearchParams({ sessionId, seq: String(seq) })}` } /** * Build authenticated coordinates for a changed file or the changed files' common folder. * @param sessionId - owning Session. * @param seq - workspace/changes event sequence. - * @param index - original index in the event's files array, or null for the common folder. + * @param index - original index in the summary's files array, or null for the common folder. * @returns same-origin action URL. */ export function changedFileUrl(sessionId: SessionId, seq: number, index: number | null): string { diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 6c4c5f62c2..29f87306b2 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -4,11 +4,10 @@ import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { changedFileUrl } from '../changes.ts' +import { changedFileUrl, type ChangesSummary } from '../changes.ts' import type { PresentedHost } from '../presented.ts' import { IconCodeBracketsOutline16 } from './icons.tsx' import type { PresentedOpenPhase } from './present-open.ts' -import type { ChangesTurnData } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ChangedFiles.module.css' @@ -51,7 +50,8 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop * @returns the card. */ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openFile, t }: { - changes: ChangesTurnData + /** The served summary with the sequence of the event that announced it. */ + changes: Pick & { seq: number } cwd: string | undefined sessionId: SessionId host: PresentedHost | null diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 7e1f9a1238..05cd5d73da 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,13 +1,15 @@ -/** The changed-files card and explicitly declared files for a closing turn. */ +/** The changed-files card, shown only while the Host serves the turn's summary, and explicitly declared files for a closing turn. */ import { useEffect, useState } from 'react' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { Button, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' +import type { ChangesSummaryStore } from './changes-summary.ts' import { ChangedFiles } from './ChangedFiles.tsx' import { changesForClosing, presentedForClosing, type ChangesTurnData, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' +import { changesSummaryUrl } from '../changes.ts' import { presentedFileUrl } from '../presented.ts' import { PresentedFileCard } from './PresentedFileCard.tsx' import css from './Deliverables.module.css' @@ -16,21 +18,23 @@ interface DeliverablesMatch { changes: ChangesTurnData | null; presented: readon const COLLAPSED_PRESENTED_COUNT = 4 -/** Native-open callbacks and shared gesture status supplied by the plugin. */ +/** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */ export interface DeliverablesInjected { hooks: { presentedOpen: ObservableSnapshot> presentedHost: ObservableSnapshot> + changesSummary: ObservableSnapshot> } reloadPresentedHost: PresentedOpenController['loadHost'] + loadChangesSummary: ChangesSummaryStore['load'] openPresented: PresentedOpenController['open'] openChanged: PresentedOpenController['openChanged'] } /** - * Claim turns with a recorded change summary or declared files. + * Claim turns with a change announcement or declared files. * @param owner - closing turn. - * @returns matched changes and deliveries, or null for a turn with neither. + * @returns matched announcement and deliveries, or null for a turn with neither. */ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch | null { const changes = changesForClosing(owner) @@ -39,17 +43,30 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch } /** - * Render the changed-files card and default-application buttons for declared files. - * @param props - matched files, workspace opener, and localized copy. + * Render the changed-files card, once the Host has served the announced + * summary and it lists a file, and default-application buttons for declared + * files. A summary the Host no longer serves leaves no card. + * @param props - matched announcement and files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, openChanged, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { +export function Deliverables({ + matched, openFile, t, sessionId, useSessions, openPresented, openChanged, usePresentedOpen, usePresentedHost, useChangesSummary, + reloadPresentedHost, loadChangesSummary, +}: Pick & { matched: DeliverablesMatch } & PropsLocale & Pick & Pick & InjectFace) { const [expanded, setExpanded] = useState(false) const cwd = useSessions(state => state.byId[sessionId]?.cwd) const states = usePresentedOpen(value => value) const host = usePresentedHost(value => value) + const announced = matched.changes + const summary = useChangesSummary(value => announced === null ? undefined : value[changesSummaryUrl(sessionId, announced.seq)]) + useEffect(() => { + if (announced !== null && summary === undefined) void loadChangesSummary(sessionId, announced.seq) + }, [announced, summary, sessionId, loadChangesSummary]) + const changes = announced !== null && typeof summary === 'object' && summary.files.length > 0 + ? { seq: announced.seq, files: summary.files, total: summary.total } + : null const collapsible = matched.presented.length > COLLAPSED_PRESENTED_COUNT const presented = collapsible && !expanded ? matched.presented.slice(0, COLLAPSED_PRESENTED_COUNT) @@ -57,14 +74,13 @@ export function Deliverables({ matched, openFile, t, sessionId, useSessions, ope useEffect(() => { if (host === null) void reloadPresentedHost() }, [host, reloadPresentedHost]) - const { changes } = matched return <> {changes !== null && { void openChanged(sessionId, changes.seq, index) }} />} {matched.presented.length > 0 &&
      {host === 'error' &&
      {t('presented.hostError')} diff --git a/packages/client/ui-deliverables/src/client/changes-summary.ts b/packages/client/ui-deliverables/src/client/changes-summary.ts new file mode 100644 index 0000000000..1aca9131e9 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/changes-summary.ts @@ -0,0 +1,60 @@ +/** Fetch-once cache of the change summaries the Host serves for announced `workspace/changes` events. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { changesSummaryUrl, isChangesSummary, type ChangesSummary } from '../changes.ts' + +/** A summary, `'missing'` once the Host no longer serves it, or `'loading'` while the request runs. */ +export type ChangesSummaryState = ChangesSummary | 'missing' | 'loading' + +/** One browser plugin's summary reads, cleared on connection replacement and cancelled on disposal. */ +export class ChangesSummaryStore { + /** Summary URLs key the state across Sessions and turns. */ + readonly state = createSnapshotStore>({}) + private readonly lifetime = new AbortController() + private readonly pending = new Set>() + + /** + * Read one summary once; a later read of the same coordinates returns the cached state. + * @param sessionId - viewed Session. + * @param seq - the announcing event's sequence. + * @returns after the state is published. + */ + async load(sessionId: SessionId, seq: number): Promise { + const url = changesSummaryUrl(sessionId, seq) + if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] !== undefined) return + this.state.update((state) => { state[url] = 'loading' }) + const task = this.read(url) + this.pending.add(task) + try { + await task + } finally { + this.pending.delete(task) + } + } + + /** Forget every state; a replaced connection may reach a Host that no longer serves them. */ + reset(): void { + this.state.set({}) + } + + /** Cancel outstanding reads and wait until none can publish state. */ + async dispose(): Promise { + this.lifetime.abort() + await Promise.all(this.pending) + } + + private async read(url: string): Promise { + let next: ChangesSummaryState = 'missing' + try { + const response = await fetch(url, { signal: this.lifetime.signal }) + if (response.ok) { + const value: unknown = await response.json() + if (isChangesSummary(value)) next = value + } + } catch { + // A transport failure reads as missing until the connection is replaced and the store reset. + next = 'missing' + } + if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = next }) + } +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 42a3e5a0d5..3f97942fb4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -14,6 +14,7 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { ChangesSummaryStore } from './changes-summary.ts' import { PresentedOpenController } from './present-open.ts' import { PresentRow } from './PresentRow.tsx' import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx' @@ -38,8 +39,12 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se */ export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() - ctx.effect(() => () => opener.dispose()) - ctx.on('connection/reset', () => { opener.resetHost() }) + const summaries = new ChangesSummaryStore() + ctx.effect(() => () => Promise.all([opener.dispose(), summaries.dispose()])) + ctx.on('connection/reset', () => { + opener.resetHost() + summaries.reset() + }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -49,8 +54,9 @@ export function apply(ctx: ClientContext): void { select: selectDeliverables, locale: NS, inject: (): DeliverablesInjected => ({ - hooks: { presentedOpen: opener.state, presentedHost: opener.host }, + hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state }, reloadPresentedHost: () => opener.loadHost(), + loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), }), diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index f29aa54718..87d13e5e41 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -9,8 +9,7 @@ import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types' -import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' -import { isChangesData } from '../changes.ts' +import { isChangesEvent } from '../changes.ts' import { basename, isPresentedData, isPresentedFile } from '../presented.ts' /** A declared file with its authorized open coordinates. */ @@ -24,11 +23,9 @@ interface ProducedPath { readonly path: string } -/** The latest recorded change summary of one Turn with its open coordinates. */ +/** The latest `workspace/changes` announcement of one Turn; the Host serves its summary by this sequence. */ export interface ChangesTurnData { readonly seq: number - readonly files: readonly WorkspaceChangedFile[] - readonly total: number } /** Immutable produced-file facts published against one Turn. */ @@ -171,7 +168,7 @@ export const deliverablesDefinition: ConversationNodeDefinition { - if (match.event.type === 'workspace/changes') { - const { files, total } = match.event.data - return { ...context.state, changes: { seq: match.event.seq, files, total } } - } + if (match.event.type === 'workspace/changes') return { ...context.state, changes: { seq: match.event.seq } } if (match.event.type === 'deliverables/presented') { const { files } = match.event.data const seq = match.event.seq @@ -236,13 +230,12 @@ export const deliverablesDefinition: ConversationNodeDefinition Promise.resolve(Response.json(ctx.sessionController.workspaceDesktop() satisfies PresentedHost, { headers: { 'cache-control': 'no-store' } })), }) + ctx.connection.fetch.register({ + path: CHANGED_FILES_PATH, methods: ['GET'], requestBody: 'buffered', + fetch: request => Promise.resolve(handleChangesSummary(ctx, request)), + }) const lifetime = new AbortController() const pending = new Set>() ctx.effect(() => async () => { @@ -135,6 +140,18 @@ export function commonChangedFolder(cwd: string, files: readonly WorkspaceChange return rel.startsWith('..') || isAbsolute(rel) ? cwd : common } +/** The summary one `workspace/changes` event announced, without the Host working directory; 404 once the Host no longer serves it. */ +function handleChangesSummary(ctx: Context, request: Request): Response { + const query = new URL(request.url).searchParams + const id = query.get('sessionId') + const seq = coordinate(query.get('seq')) + if (!id || seq === undefined) return new Response('Invalid change summary coordinates.', { status: 400 }) + const summary = ctx.workspaceChanges.summary(id as SessionId, seq) + if (summary === undefined) return new Response('Change summary unavailable.', { status: 404 }) + const { turn, files, total } = summary + return Response.json({ turn, files, total } satisfies ChangesSummary, { headers: { 'cache-control': 'no-store' } }) +} + async function handleChangesOpen(ctx: Context, request: Request): Promise { const query = new URL(request.url).searchParams const id = query.get('sessionId') @@ -143,13 +160,11 @@ async function handleChangesOpen(ctx: Context, request: Request): Promise Promise> = [] afterEach(async () => { @@ -34,31 +33,51 @@ async function fixture() { await writeFile(join(cwd, 'src', 'b.ts'), 'b') const outside = join(root, 'outside.txt') await writeFile(outside, 'outside') - const data = { turn: 1, total: 3, snapshot: { before: 'a'.repeat(40), after: 'b'.repeat(40) }, files: [changed('src/lib/a.ts'), changed('src/b.ts'), changed(outside, '~/outside.txt')] } + const data: WorkspaceChangesSummary = { turn: 1, cwd, total: 3, files: [changed('src/lib/a.ts'), changed('src/b.ts'), changed(outside, '~/outside.txt')] } const ctx = new Context() cleanups.push(() => ctx.fiber.dispose()) - const session: { cwd?: string } = { cwd } await ctx.plugin(LocalFileSystem, { cwd }) ctx.provide('sandboxPolicy', { workspaceRoot: cwd } as never) await ctx.plugin({ inject: ['fs', 'sandboxPolicy'], apply: (scope) => { new WorkspaceFiles(scope, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) }, }) - const readEvent = vi.fn(async (request: SessionEventReadRequest) => { - if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') - if (request.seq !== 9) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') - return { session, target: { type: 'workspace/changes', data } as SessionEvent } + const readEvent = vi.fn(async (_request: SessionEventReadRequest) => { + throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') }) ctx.provide('sessionQuery', { readEvent } as never) + const summary = vi.fn((sessionId: SessionId, seq: number) => sessionId === 'owner' && seq === 9 ? data : undefined) + ctx.provide('workspaceChanges', { summary }) const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) - await ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen }) + await ctx.plugin({ + inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'], + apply: registerPresentOpen, + }) const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' })) - return { root, cwd, ctx, data, session, readEvent, open, opener, outside } + const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`)) + return { root, cwd, ctx, data, readEvent, open, read, opener, outside, summary } } +describe('change summary route', () => { + it('serves the Host-held summary without its working directory, and 404 once it is gone', async () => { + const { read, data, summary } = await fixture() + expect(changesSummaryUrl(SessionId('owner'), 9)).toBe(`${CHANGED_FILES_PATH}?sessionId=owner&seq=9`) + const response = await read() + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.json()).toEqual({ turn: 1, total: 3, files: data.files }) + expect((await read('?sessionId=owner&seq=8')).status).toBe(404) + expect((await read('?sessionId=other&seq=9')).status).toBe(404) + for (const bad of ['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=x', '?sessionId=owner&seq=1.5']) { + expect((await read(bad)).status).toBe(400) + } + expect(summary).toHaveBeenCalledTimes(3) + }) +}) + describe('changed files native open route', () => { it('opens a listed file inside or outside the workspace with its verified Host path', async () => { const { cwd, open, opener, outside } = await fixture() @@ -92,17 +111,12 @@ describe('changed files native open route', () => { expect(readEvent).not.toHaveBeenCalled() }) - it('refuses unrelated Sessions, other events, malformed data, unknown indices, and missing files', async () => { - const { open, readEvent, session, opener, cwd, data } = await fixture() + it('refuses unrelated Sessions, forgotten summaries, unknown indices, and missing files', async () => { + const { open, readEvent, opener, cwd } = await fixture() expect((await open('?sessionId=other&seq=9&index=0')).status).toBe(404) expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404) expect((await open('?sessionId=owner&seq=9&index=5')).status).toBe(404) - readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data } as unknown as SessionEvent }) - expect((await open()).status).toBe(404) - for (const bad of [null, { turn: 0, files: [], total: 0 }, { turn: 1, files: null, total: 0 }, { turn: 1, files: [{ path: 'a' }], total: 1 }, { turn: 1, files: [], total: 1.5 }]) { - readEvent.mockResolvedValueOnce({ session, target: { type: 'workspace/changes', data: bad } as unknown as SessionEvent }) - expect((await open()).status).toBe(404) - } + expect(readEvent).not.toHaveBeenCalled() await unlink(join(cwd, 'src', 'lib', 'a.ts')) expect((await open()).status).toBe(404) await rm(join(cwd, 'src'), { recursive: true }) @@ -126,22 +140,24 @@ describe('changed files native open route', () => { expect((await open()).status).toBe(204) }) - it('uses the deployment workspace root when the viewed Session has no cwd', async () => { - const { session, open, opener, cwd } = await fixture() - delete session.cwd - expect((await open('?sessionId=owner&seq=9')).status).toBe(204) - expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, 'src'))) - }) - - it('validates recorded change records', () => { + it('validates served summaries and logged announcements', () => { expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: true })).toBe(true) expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: false })).toBe(false) expect(isChangedFile({ path: '', display: 'a', added: 1, deleted: 2 })).toBe(false) expect(isChangedFile({ path: 'a', display: '', added: 1, deleted: 2 })).toBe(false) expect(isChangedFile({ path: 'a', display: 'a', added: 1.5, deleted: 2 })).toBe(false) expect(isChangedFile([])).toBe(false) - expect(isChangesData({ turn: 1, total: 0, files: [] })).toBe(true) - expect(isChangesData({ turn: '1', total: 0, files: [] })).toBe(false) - expect(isChangesData([])).toBe(false) + expect(isChangesSummary({ turn: 1, total: 0, files: [] })).toBe(true) + expect(isChangesSummary({ turn: 1, total: 1, files: [{ path: 'a', display: 'a', added: 1, deleted: 0 }] })).toBe(true) + expect(isChangesSummary({ turn: '1', total: 0, files: [] })).toBe(false) + expect(isChangesSummary({ turn: 0, total: 0, files: [] })).toBe(false) + expect(isChangesSummary({ turn: 1, total: 1.5, files: [] })).toBe(false) + expect(isChangesSummary({ turn: 1, total: 1, files: [{ path: 'a' }] })).toBe(false) + expect(isChangesSummary([])).toBe(false) + expect(isChangesEvent({ turn: 1 })).toBe(true) + expect(isChangesEvent({ turn: 1, extra: true })).toBe(true) + expect(isChangesEvent({ turn: 0 })).toBe(false) + expect(isChangesEvent({ turn: '1' })).toBe(false) + expect(isChangesEvent(null)).toBe(false) }) }) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index d3afb6059f..a6c0e52718 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -23,22 +23,27 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' +import { ChangesSummaryStore } from '../src/client/changes-summary.ts' +import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts' import { PresentedOpenController } from '../src/client/present-open.ts' import { basename, changesForClosing, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, - selectProducedFiles, type ChangesTurnData, type DeliverablesTurnData, + selectProducedFiles, type DeliverablesTurnData, } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { en, zh } from '../src/client/locales.ts' import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -function openProps(controller = new PresentedOpenController()) { +function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) { controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } return { useSessions: (select: (state: SessionListState) => T): T => select(sessions), reloadPresentedHost: vi.fn(() => controller.loadHost()), + useChangesSummary: (select: (state: ReturnType) => T): T => + select(summaries.state.getSnapshot()), + loadChangesSummary: vi.fn((...args: Parameters) => summaries.load(...args)), usePresentedHost: (select: (state: ReturnType) => T): T => select(controller.host.getSnapshot()), openPresented: vi.fn((...args: Parameters) => controller.open(...args)), @@ -428,30 +433,28 @@ describe('produced-file Turn data', () => { const changedFile = (display: string, added = 1, deleted = 0, extra: { binary?: true; path?: string } = {}) => ({ path: extra.path ?? display, display, added, deleted, ...extra.binary === true ? { binary: true as const } : {} }) -const changesEvent = (seq: number, files: ReturnType[], turn = 1, total = files.length) => - at(seq, 'workspace/changes', { turn, files, total, snapshot: { before: 'a'.repeat(40), after: 'b'.repeat(40) } }) +const changesEvent = (seq: number, turn = 1) => at(seq, 'workspace/changes', { turn }) -describe('recorded changes Turn data', () => { - it('keeps the latest valid summary of the turn and ignores malformed or empty ones for the card', () => { +describe('announced changes Turn data', () => { + it('keeps the latest valid announcement of the turn and ignores malformed ones', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), - changesEvent(2, [changedFile('old.ts')]), - at(3, 'workspace/changes', { turn: 1, files: [{ path: 'x' }], total: 1 }), + changesEvent(2), + at(3, 'workspace/changes', { turn: 'x' }), at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), - changesEvent(5, [changedFile('src/a.ts', 3, 1), changedFile('b.bin', 0, 0, { binary: true })], 1, 7), + changesEvent(5), at(6, 'turn/start', { turn: 2 }), - changesEvent(7, [], 2), ]) const owner = tailOwner(deliverablesOf(value), 4) - expect(changesForClosing(owner)).toEqual({ seq: 5, total: 7, files: [changedFile('src/a.ts', 3, 1), changedFile('b.bin', 0, 0, { binary: true })] }) - expect(selectDeliverables(owner)).toEqual({ changes: changesForClosing(owner), presented: [] }) + expect(changesForClosing(owner)).toEqual({ seq: 5 }) + expect(selectDeliverables(owner)).toEqual({ changes: { seq: 5 }, presented: [] }) expect(changesForClosing(tailOwner(deliverablesOf(value, 2), 8))).toBeNull() expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 8))).toBeNull() expect(changesForClosing(tailOwner(undefined, 8))).toBeNull() }) it('preserves Turn data identity across unrelated appends', () => { - const value = assembler([at(1, 'turn/start', { turn: 1 }), changesEvent(2, [changedFile('a.ts')])]) + const value = assembler([at(1, 'turn/start', { turn: 1 }), changesEvent(2)]) const first = deliverablesOf(value) value.append(call(3, 'later', 'read', { file_path: 'a.ts' })) value.flush() @@ -465,16 +468,76 @@ describe('ChangedFiles card', () => { changedFile('config/launch-plan.yaml', 654, 9), changedFile('src/index.ts', 393, 274), changedFile('~/.zshrc', 0, 0, { binary: true, path: '/home/u/.zshrc' }), ] - const changes: ChangesTurnData = { seq: 5, files, total: 11 } + const changes = { seq: 5 } + const served: ChangesSummary = { turn: 1, files, total: 11 } - function renderCard(controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }) { - const props = openProps(controller) + /** A store already holding the summary the Host served for the announced sequence. */ + function servedStore(summary: ChangesSummary = served, seq = 5) { + const summaries = new ChangesSummaryStore() + summaries.state.set({ [changesSummaryUrl(SessionId('child-session'), seq)]: summary }) + return summaries + } + + function renderCard( + controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }, summaries = servedStore(), + ) { + const props = openProps(controller, summaries) props.openChanged.mockResolvedValue(undefined) const openFile = vi.fn<(path: string) => void>() const view = render() return { props, openFile, view } } + it('reads the announced summary once and renders nothing while it loads, when it is gone, or when it lists no file', async () => { + const summaries = new ChangesSummaryStore() + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.endsWith('seq=5')) return Response.json(served) + if (url.endsWith('seq=6')) return Response.json({ turn: 1, files: [], total: 0 }) + return new Response('gone', { status: 404 }) + }) + vi.stubGlobal('fetch', fetchMock) + const { props, view } = renderCard(new PresentedOpenController(), en, { changes, presented: [] as never[] }, summaries) + expect(view.container.querySelector('[data-changed-files]')).toBeNull() + expect(props.loadChangesSummary).toHaveBeenCalledWith('child-session', 5) + await vi.waitFor(() => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toEqual(served) }) + view.rerender( {}} sessionId={SessionId('child-session')} t={makeTranslate(en)} />) + expect(view.getByText('Edited 11 files')).toBeTruthy() + for (const seq of [6, 7]) { + view.rerender( {}} sessionId={SessionId('child-session')} t={makeTranslate(en)} />) + await vi.waitFor(() => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), seq)]).not.toBe('loading') }) + view.rerender( {}} sessionId={SessionId('child-session')} t={makeTranslate(en)} />) + expect(view.container.querySelector('[data-changed-files]')).toBeNull() + } + expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 7)]).toBe('missing') + await summaries.load(SessionId('child-session'), 5) + expect(fetchMock).toHaveBeenCalledTimes(3) + // A replaced connection forgets every read; a later mount asks the new Host again. + summaries.reset() + expect(summaries.state.getSnapshot()).toEqual({}) + fetchMock.mockRejectedValueOnce(new Error('offline')) + await summaries.load(SessionId('child-session'), 5) + expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('missing') + fetchMock.mockResolvedValueOnce(Response.json({ turn: 'x' })) + await summaries.load(SessionId('child-session'), 8) + expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 8)]).toBe('missing') + await summaries.dispose() + await summaries.load(SessionId('child-session'), 9) + expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 9)]).toBeUndefined() + }) + + it('drops a read that settles after disposal', async () => { + const summaries = new ChangesSummaryStore() + let settle!: (response: Response) => void + vi.stubGlobal('fetch', vi.fn(() => new Promise((resolve) => { settle = resolve }))) + const loading = summaries.load(SessionId('child-session'), 5) + expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading') + const disposal = summaries.dispose() + settle(Response.json(served)) + await Promise.all([loading, disposal]) + expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading') + }) + it('summarizes the turn, folds after three rows, and opens rows and the folder natively', () => { const { props, openFile, view } = renderCard() const card = view.container.querySelector('[data-changed-files]') @@ -555,8 +618,8 @@ describe('ChangedFiles card', () => { }) it('renders without a fold for three files or fewer and beside delivery cards', () => { - const short: ChangesTurnData = { seq: 5, files: files.slice(0, 2), total: 2 } - const { view } = renderCard(new PresentedOpenController(), en, { changes: short, presented: [{ path: 'report.pdf', seq: 6, index: 0 }] as never[] }) + const short = servedStore({ turn: 1, files: files.slice(0, 2), total: 2 }) + const { view } = renderCard(new PresentedOpenController(), en, { changes, presented: [{ path: 'report.pdf', seq: 6, index: 0 }] as never[] }, short) expect(view.getByText('Edited 2 files')).toBeTruthy() expect(view.queryByRole('button', { name: /Show all|Collapse changed/ })).toBeNull() expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(1) @@ -653,8 +716,13 @@ describe('plugin registration', () => { fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) await face.reloadPresentedHost() expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' }) + fetcher.mockResolvedValueOnce(Response.json({ turn: 1, files: [], total: 0 })) + await face.loadChangesSummary(SessionId('child-session'), 5) + expect(face.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0 }) ctx.emit('connection/reset') expect(face.hooks.presentedHost.getSnapshot()).toBeNull() + // The replaced connection may reach a Host that no longer serves the summaries read so far. + expect(face.hooks.changesSummary.getSnapshot()).toEqual({}) await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') await face.openChanged(SessionId('child-session'), 5, null) @@ -741,11 +809,13 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] }, call(2, 'write-a', 'write', { file_path: 'a.txt', content: 'a' }), result(3, 'write-a'), at(4, 'deliverables/presented', data), - at(5, 'workspace/changes', { turn: 1, files: [{ path: 'a.txt', display: 'a.txt', added: 1, deleted: 0 }], total: 1 }), + at(5, 'workspace/changes', { turn: 1 }), ]) const owner = tailOwner(deliverablesOf(value), 6) const matched = selectDeliverables(owner)! - const view = render() + const summaries = new ChangesSummaryStore() + summaries.state.set({ [changesSummaryUrl(SessionId('session'), 5)]: { turn: 1, files: [{ path: 'a.txt', display: 'a.txt', added: 1, deleted: 0 }], total: 1 } }) + const view = render() expect(view.getByText('Edited 1 files')).toBeTruthy() expect(view.queryByText('Deliverables')).toBeNull() }) @@ -817,7 +887,7 @@ it('loads desktop information once the tail renders and not again while it is kn controller.host.set(null) props.reloadPresentedHost.mockResolvedValue(undefined) const shared = { ...props, openFile: () => {}, sessionId: SessionId('session'), t: makeTranslate(en) } - const view = render() + const view = render() expect(props.reloadPresentedHost).toHaveBeenCalledOnce() controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) view.rerender() diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index b56bfa3b8e..27d277f0c9 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -22,6 +22,7 @@ describe('ui-deliverables node plugin', () => { ctx.provide('workspaceFiles', {} as never) ctx.provide('fs', {} as never) ctx.provide('sandboxPolicy', {} as never) + ctx.provide('workspaceChanges', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/deliverables/README.i18n.yaml b/packages/deliverables/README.i18n.yaml index c5873b1ab9..bf5f2f81a2 100644 --- a/packages/deliverables/README.i18n.yaml +++ b/packages/deliverables/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/deliverables/README.md -README.md: ff2980f8cd190281010fcce3cd5c8a27f7d4213d -README.zh.md: c9bf60f3e765214608ed4d1d1fc25b1a2115460e +README.md: 2d3a6908798a2e1565186a016d33d1a806f256d7 +README.zh.md: b9d000c923028168ba818ff995a25372f2203a93 diff --git a/packages/deliverables/README.md b/packages/deliverables/README.md index ff2980f8cd..2d3a690879 100644 --- a/packages/deliverables/README.md +++ b/packages/deliverables/README.md @@ -32,7 +32,7 @@ The deliverables family records what a turn hands to the user as durable Session ## Related documentation -- [Deliverables subsystem](../../docs/subsystems/deliverables.md) — the `PresentedFile` and `WorkspaceChangesData` vocabulary and the two durable events. +- [Deliverables subsystem](../../docs/subsystems/deliverables.md) — the `PresentedFile` and `WorkspaceChangesSummary` vocabulary, the two durable events, and the summary service. - [Web deliverables](../client/ui-deliverables/README.md) — the turn-tail cards and file mentions that render these events. - [Present declares workspace source files](../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md) — the delivery decision. - [Turn changed-files card](../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the snapshot design and coverage rules. diff --git a/packages/deliverables/README.zh.md b/packages/deliverables/README.zh.md index c9bf60f3e7..b9d000c923 100644 --- a/packages/deliverables/README.zh.md +++ b/packages/deliverables/README.zh.md @@ -32,7 +32,7 @@ deliverables 系列把一轮交给用户的内容记录为只有客户端读取 ## 相关文档 -- [产出物子系统](../../docs/subsystems/deliverables.zh.md)——`PresentedFile` 与 `WorkspaceChangesData` 的词汇和两个持久事件。 +- [产出物子系统](../../docs/subsystems/deliverables.zh.md)——`PresentedFile` 与 `WorkspaceChangesSummary` 的词汇、两个持久事件和摘要服务。 - [Web 产出物](../client/ui-deliverables/README.zh.md)——渲染这些事件的轮尾卡片与文件提及。 - [present 声明工作区源文件](../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md)——交付决策。 - [本轮改动文件卡片](../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计与覆盖规则。 diff --git a/packages/deliverables/workspace-changes/README.i18n.yaml b/packages/deliverables/workspace-changes/README.i18n.yaml index 84870f65eb..f0f99520f7 100644 --- a/packages/deliverables/workspace-changes/README.i18n.yaml +++ b/packages/deliverables/workspace-changes/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/deliverables/workspace-changes/README.md -README.md: eadde42f30d3909bdce75641f0c73c7f4ec1fa2c -README.zh.md: a5a49ea5214e01325e4fcfcd01713048c1fada90 +README.md: cf0bc63290f3e4820e21c966642ed7c28c441cc3 +README.zh.md: 3d551c79f62c3ab42bbaf0fc3f1467b783563676 diff --git a/packages/deliverables/workspace-changes/README.md b/packages/deliverables/workspace-changes/README.md index eadde42f30..cf0bc63290 100644 --- a/packages/deliverables/workspace-changes/README.md +++ b/packages/deliverables/workspace-changes/README.md @@ -1,5 +1,5 @@ --- -description: "Record each top-level turn's changed files from git working-tree snapshots as a durable workspace/changes Session event; configuration, repository requirement, and coverage rules." +description: "Summarize each top-level turn's changed files from git working-tree snapshots, announce them with a workspace/changes Session event, and serve the summary while the Session lives; configuration, repository requirement, and coverage rules." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This plugin records which files each top-level turn changed, with per-file added and deleted line counts, as a `workspace/changes` Session event. It snapshots the working tree with git when a turn starts and when it ends, diffs the two snapshots, and adds the file-tool edits that git does not cover. Only a working directory inside a git repository is recorded. The Web changed-files card renders the event; the model never sees it. +This plugin summarizes which files each top-level turn changed, with per-file added and deleted line counts. It snapshots the working tree with git when a turn starts and when it ends, diffs the two snapshots, and adds the file-tool edits that git does not cover. The Session log receives only a `workspace/changes` event naming the turn; the summary itself stays on the Host, served through the `workspaceChanges` service until the Session is disposed. Only a working directory inside a git repository is recorded. The Web changed-files card renders the served summary; the model never sees it. ## Table of Contents @@ -35,17 +35,15 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the | Field | Default | Meaning | |---|---|---| -| `dshHome` | `$DSH_HOME`, then `~/.dsh` | Harness home whose `workspace-changes/` directory holds one snapshot object store per repository | -| `objectStoreMaxBytes` | `1073741824` | Bytes one repository's snapshot object store may hold before it is discarded and restarted empty | | `timeoutMs` | `30000` | Milliseconds one git command may run before the turn's record is abandoned | | `outputMaxBytes` | `8388608` | Bytes of git output retained per command; a larger diff listing abandons the record | -| `maxFiles` | `500` | Maximum files carried by one event; `total` still reports the complete count | +| `maxFiles` | `500` | Maximum files carried by one summary; `total` still reports the complete count | -Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots are written through a private index into a private object store under the Harness home, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. One store serves every Session of a repository; a Session checks its size when it first locates the repository and discards a store over `objectStoreMaxBytes`, which starts empty. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once. +Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots are written through a private index into a temporary object directory owned by the Session, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. Session disposal removes the directory. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once. Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results, or from the call's own arguments when the result persists none — a `write` that creates a file and every `str_replace_editor` mutation: files matching an ignore pattern and files outside the repository. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded. -Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The event also carries the two snapshot tree ids. +Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The `workspace/changes` event carries only the turn number; `ctx.workspaceChanges.summary(sessionId, seq)` returns the summary the event with that sequence announced, or undefined once the Session is disposed or when this Host process never recorded it. A conversation reopened after a Host restart therefore has no card for its earlier turns. ----- @@ -55,11 +53,11 @@ Each file carries a durable `path` — relative to the working directory inside
      Implementation internals — click to expand -One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository and its object store once per Session, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the private store and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/call` events keep each mutation call's argument-derived hunks and `tool/result` events collect the persisted ones, preferring the latter. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read. +One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository once per Session and the Session's temporary object directory is created, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the temporary directory and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/call` events keep each mutation call's argument-derived hunks and `tool/result` events collect the persisted ones, preferring the latter. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, the appended event, and the summary kept under the event's sequence. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read. -Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work. +Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work, forget the summaries, and remove the temporary directory. -**Runtime invariant:** No companion is published. Event listeners are effect-owned, the Session log owns the recorded summaries, and the git object store owns snapshot trees; no independent observation can diverge from them. +**Runtime invariant:** No companion is published. Event listeners are effect-owned and the recorder owns both the summaries and the snapshot trees for its Session's lifetime; no independent observation can diverge from them.
      @@ -68,7 +66,7 @@ Git runs through the `subprocess` capability with a scrubbed environment, `GIT_T ## Further Exploration -- [Web deliverables](../../client/ui-deliverables/README.md) — the changed-files card that renders this event and opens its files. +- [Web deliverables](../../client/ui-deliverables/README.md) — the changed-files card that reads the served summary and opens its files. - [Subprocess capability](../../subprocess/README.md) — the seam git runs through. - [Turn changed-files card decision](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — snapshot design, coverage rules, the deferred shadow repository, and rejected alternatives. @@ -85,8 +83,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected. -- Snapshot trees survive only until their private store exceeds `objectStoreMaxBytes` and is discarded; the recorded counts survive, the trees needed for a later full-file comparison do not. -- The store is shared by every Session of one repository. A Session that discards it while another Session is snapshotting makes that other turn warn and record nothing; the next turn starts afresh. +- Summaries and snapshot trees live only as long as their Session in this Host process; earlier turns of a conversation reopened after a Host restart have no card. This is the decided behavior: a card whose content the Host can no longer open is not shown. - Two git features still write into the repository's own git directory during a snapshot: `core.splitIndex` writes `sharedindex.*` files, and git-lfs runs its clean filter on changed files and stores their objects under `.git/lfs`. - git 2.13 or later is required for `rev-parse --absolute-git-dir`; an unsupported repository format or another git failure abandons the turn with a warning rather than being treated as a plain directory. - Edits the user makes during a turn are attributed to that turn. diff --git a/packages/deliverables/workspace-changes/README.zh.md b/packages/deliverables/workspace-changes/README.zh.md index a5a49ea521..3d551c79f6 100644 --- a/packages/deliverables/workspace-changes/README.zh.md +++ b/packages/deliverables/workspace-changes/README.zh.md @@ -1,5 +1,5 @@ --- -description: "用 git 工作树快照把每个顶层轮次改动的文件记录为持久的 workspace/changes Session 事件;配置、仓库要求与覆盖规则。" +description: "用 git 工作树快照汇总每个顶层轮次改动的文件,以 workspace/changes Session 事件宣告,并在 Session 存活期间提供摘要;配置、仓库要求与覆盖规则。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本插件把每个顶层轮次改动了哪些文件、每个文件的增删行数记录为 `workspace/changes` Session 事件。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。只有位于 git 仓库内的工作目录会被记录。Web 的改动文件卡片渲染该事件;模型看不到它。 +本插件汇总每个顶层轮次改动了哪些文件、每个文件的增删行数。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要本身留在 Host 上,通过 `workspaceChanges` 服务提供,直到 Session 释放。只有位于 git 仓库内的工作目录会被记录。Web 的改动文件卡片渲染提供的摘要;模型看不到它。 ## 目录 @@ -35,17 +35,15 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `dshHome` | `$DSH_HOME`,其次 `~/.dsh` | Harness home,其 `workspace-changes/` 目录为每个仓库存放一个快照对象库 | -| `objectStoreMaxBytes` | `1073741824` | 单个仓库的快照对象库允许的字节数,超过后丢弃并从空开始 | | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 | | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 | -| `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 | +| `maxFiles` | `500` | 单份摘要携带的最大文件数;`total` 仍报告完整数量 | -工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。一个仓库的所有 Session 共用一个对象库;Session 在首次定位仓库时检查其大小,超过 `objectStoreMaxBytes` 的对象库被丢弃,从空开始。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 +工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入,结果没有持久化 hunk 时则取调用自身的参数,也就是新建文件的 `write` 和 `str_replace_editor` 的每一种修改:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。 -每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。事件还携带两次快照的 tree id。 +每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。`workspace/changes` 事件只携带轮号;`ctx.workspaceChanges.summary(sessionId, seq)` 返回该序号的事件宣告的摘要,Session 已释放或本 Host 进程从未记录时返回 undefined。因此 Host 重启后重新打开的对话,先前轮次没有卡片。 ----- @@ -55,11 +53,11 @@ kind: "package-reference"
      实现细节——点击展开 -每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库及其对象库,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向私有对象库、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/call` 事件保留每个修改调用由参数推出的 hunk,`tool/result` 事件收集持久化的 hunk,后者优先。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。 +每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/call` 事件保留每个修改调用由参数推出的 hunk,`tool/result` 事件收集持久化的 hunk,后者优先。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`、追加事件,以及按事件序号保存摘要。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。 -git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作。 +git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。 -**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,Session 日志拥有记录的摘要,git 对象库拥有快照树;没有独立观察会与它们分歧。 +**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,记录器在其 Session 存活期间同时拥有摘要与快照树;没有独立观察会与它们分歧。
      @@ -68,7 +66,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ ## 进一步探索 -- [Web 产出物](../../client/ui-deliverables/README.zh.md)——渲染该事件并打开其文件的改动文件卡片。 +- [Web 产出物](../../client/ui-deliverables/README.zh.md)——读取所提供摘要并打开其文件的改动文件卡片。 - [子进程能力](../../subprocess/README.zh.md)——git 运行所经过的接缝。 - [本轮改动文件卡片决策](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——快照设计、覆盖规则、暂缓的影子仓库与被否决的备选方案。 @@ -85,8 +83,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ -- 快照树只保留到其私有对象库超过 `objectStoreMaxBytes` 被丢弃为止;记录的计数保留,而将来做整文件对比所需的树不会保留。 -- 对象库由一个仓库的所有 Session 共用。一个 Session 在另一个 Session 快照途中丢弃它,会让那一轮告警并且不记录;下一轮重新开始。 +- 摘要与快照树只在本 Host 进程内随其 Session 存活;Host 重启后重新打开的对话,先前轮次没有卡片。这是既定行为:Host 已经打不开内容的卡片不显示。 - 有两个 git 功能在快照期间仍会写入仓库自己的 git 目录:`core.splitIndex` 会写 `sharedindex.*` 文件,git-lfs 会对改动文件运行 clean 过滤器并把对象存到 `.git/lfs` 下。 - 需要 git 2.13 或更高版本以支持 `rev-parse --absolute-git-dir`;不支持的仓库格式或其他 git 失败会带着警告放弃本轮,而不是被当成普通目录。 - 用户在轮次进行中自己做的编辑会被算到该轮。 diff --git a/packages/deliverables/workspace-changes/package.json b/packages/deliverables/workspace-changes/package.json index 6c3d899880..f048467c8b 100644 --- a/packages/deliverables/workspace-changes/package.json +++ b/packages/deliverables/workspace-changes/package.json @@ -38,7 +38,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^" @@ -48,7 +47,6 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/packages/deliverables/workspace-changes/src/git.ts b/packages/deliverables/workspace-changes/src/git.ts index 91e7698168..a0cb5c0171 100644 --- a/packages/deliverables/workspace-changes/src/git.ts +++ b/packages/deliverables/workspace-changes/src/git.ts @@ -1,7 +1,5 @@ /** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */ -import { createHash } from 'node:crypto' -import { existsSync } from 'node:fs' -import { copyFile, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises' +import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' @@ -96,58 +94,37 @@ export interface GitWorkspace { root: string /** Absolute git directory holding the repository's index. */ gitDir: string - /** Directory under the Harness home that receives every snapshot blob and tree. */ - objectsDir: string - /** Environment that routes object writes to {@link objectsDir} and object reads through the repository's store. */ + /** Environment that routes object writes to the private store and object reads through the repository's store. */ env: Readonly> } -/** Placement and bound of the private snapshot object stores. */ -export interface ObjectStoreOptions { - /** Directory that holds one object store per repository. */ - home: string - /** Bytes one repository's store may hold; a larger store is discarded before the next snapshot. */ - maxBytes: number -} - /** Whether a filesystem error names a missing path. */ function isMissing(error: unknown): boolean { return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'ENOENT' } -/** Total size of the regular files under a directory; zero when it does not exist. */ -async function directoryBytes(directory: string): Promise { - if (!existsSync(directory)) return 0 - let total = 0 - for (const entry of await readdir(directory, { recursive: true, withFileTypes: true })) { - if (entry.isFile()) total += (await stat(join(entry.parentPath, entry.name))).size - } - return total -} - /** - * Locate the repository enclosing a working directory and prepare its snapshot - * object store. The repository's own object store is attached read-only as an - * alternate, so snapshots read committed content from it and write nothing - * into it; a store over its byte bound is discarded and starts empty. A - * directory outside any repository yields null; any other git failure throws. + * Locate the repository enclosing a working directory and prepare the private + * object store its snapshots write to. The repository's own object store is + * attached read-only as an alternate, so snapshots read committed content from + * it and write nothing into it. A directory outside any repository yields + * null; any other git failure throws. * @param git - command runner. * @param cwd - absolute Session working directory. - * @param store - snapshot object store placement and bound. + * @param objectsDir - yields the directory that receives every snapshot blob and tree; called only for a located repository. * @param signal - cancellation. * @returns the repository, or null when the directory is not inside one. */ export async function locateGitWorkspace( - git: GitRunner, cwd: string, store: ObjectStoreOptions, signal: AbortSignal, + git: GitRunner, cwd: string, objectsDir: () => Promise, signal: AbortSignal, ): Promise { const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-path', 'objects'], { cwd, signal }) if (found.exitCode === 128 && /not a git repository/i.test(found.stderr)) return null const lines = ok(found, 'git rev-parse').stdout.split('\n').map(line => resolve(cwd, line)) const [root, gitDir, repositoryObjects] = lines as [string, string, string] - const objectsDir = join(store.home, createHash('sha256').update(gitDir).digest('hex').slice(0, 16)) - if (await directoryBytes(objectsDir) > store.maxBytes) await rm(objectsDir, { recursive: true, force: true }) - await mkdir(objectsDir, { recursive: true }) - return { root, gitDir, objectsDir, env: { GIT_OBJECT_DIRECTORY: objectsDir, GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects } } + const objects = await objectsDir() + await mkdir(objects, { recursive: true }) + return { root, gitDir, env: { GIT_OBJECT_DIRECTORY: objects, GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects } } } /** diff --git a/packages/deliverables/workspace-changes/src/index.ts b/packages/deliverables/workspace-changes/src/index.ts index 99a9984765..e686d4c448 100644 --- a/packages/deliverables/workspace-changes/src/index.ts +++ b/packages/deliverables/workspace-changes/src/index.ts @@ -1,22 +1,23 @@ /** - * Records the files each top-level turn changed as a durable `workspace/changes` - * Session event, derived from git working-tree snapshots taken at turn start - * and turn end plus the hunks file tools persist for paths git does not cover. - * Only a working directory inside a git repository is recorded. + * Summarizes the files each top-level turn changed from git working-tree + * snapshots taken at turn start and turn end plus the hunks file tools persist + * for paths git does not cover. Each summary is announced by a `workspace/changes` + * Session event that carries only the turn number and is served through the + * `workspaceChanges` service until the Session is disposed. Only a working + * directory inside a git repository is recorded. */ -import { homedir } from 'node:os' -import { join } from 'node:path' +import { homedir, tmpdir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type {} from '@deepseek-ai/dsh-agent' -import type { Session } from '@deepseek-ai/dsh-session' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/dsh-tools' import { GitRunner } from './git.ts' import { TurnRecorder } from './recorder.ts' +import type { WorkspaceChanges } from './types.ts' -export type { WorkspaceChangedFile, WorkspaceChangesData } from './types.ts' +export type { WorkspaceChangedFile, WorkspaceChanges, WorkspaceChangesSummary } from './types.ts' /** Stable Loader identity. */ export const name = 'workspace-changes' @@ -24,24 +25,18 @@ export const name = 'workspace-changes' /** Services used to run git and observe turns. */ export const inject = ['subprocess'] -/** Snapshot bounds and object store placement. Invalid values fail plugin load. */ +/** Snapshot bounds. Invalid values fail plugin load. */ export interface Config { - /** Harness home whose `workspace-changes/` directory holds one snapshot object store per repository; `$DSH_HOME`, then `~/.dsh`. */ - dshHome?: string - /** Bytes one repository's snapshot object store may hold before it is discarded and restarted empty. */ - objectStoreMaxBytes: number /** Milliseconds one git command may run before the turn's record is abandoned. */ timeoutMs: number /** Bytes of git output retained per command; a larger diff listing abandons the record. */ outputMaxBytes: number - /** Maximum files carried by one event; `total` still reports the complete count. */ + /** Maximum files carried by one summary; `total` still reports the complete count. */ maxFiles: number } /** Schemastery validation for {@link Config}. */ export const Config: z = z.object({ - dshHome: z.string(), - objectStoreMaxBytes: z.number().default(1024 * 1024 * 1024), timeoutMs: z.number().default(30_000), outputMaxBytes: z.number().default(8 * 1024 * 1024), maxFiles: z.number().default(500), @@ -78,25 +73,32 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise() - ctx.effect(() => () => { + const byId = new Map() + const forget = (session: Session): Promise => { + const recorder = recorders.get(session) + recorders.delete(session) + byId.delete(session.id) + return recorder?.dispose() ?? Promise.resolve() + } + ctx.effect(() => async () => { lifetime.abort() - for (const recorder of recorders.values()) recorder.dispose() - recorders.clear() + await Promise.all([...recorders.keys()].map(forget)) }) - const objects = { home: join(resolveDshHome(config.dshHome), 'workspace-changes'), maxBytes: config.objectStoreMaxBytes } + const service: WorkspaceChanges = { summary: (sessionId, seq) => byId.get(sessionId)?.summary(seq) } + ctx.provide('workspaceChanges', service) let runner: Promise | undefined const gitRunner = (): Promise => { runner ??= resolveGit(ctx, lifetime.signal).then((executable) => { @@ -112,10 +114,11 @@ export function apply(ctx: Context, config: Config): void { let recorder = recorders.get(session) if (recorder === undefined) { recorder = new TurnRecorder(session, cwd, { - git: gitRunner(), objects, maxFiles: config.maxFiles, + git: gitRunner(), tempRoot: tmpdir(), maxFiles: config.maxFiles, warn: (message) => { ctx.logger.warn(message) }, }) recorders.set(session, recorder) + byId.set(session.id, recorder) } return recorder } @@ -129,10 +132,7 @@ export function apply(ctx: Context, config: Config): void { else if (event.type === 'tool/result') recorders.get(session)?.observe(event) else if (event.type === 'turn/end') recorders.get(session)?.end(event.data.turn) }) - ctx.on('session/disposed', (session) => { - recorders.get(session)?.dispose() - recorders.delete(session) - }) + ctx.on('session/disposed', (session) => { void forget(session) }) ctx.on('agent/turn-stopping', async ({ agent, turn }) => { await recorders.get(agent.session)?.stopping(turn) }) diff --git a/packages/deliverables/workspace-changes/src/recorder.ts b/packages/deliverables/workspace-changes/src/recorder.ts index c2fad1437f..1e8dda04bd 100644 --- a/packages/deliverables/workspace-changes/src/recorder.ts +++ b/packages/deliverables/workspace-changes/src/recorder.ts @@ -1,21 +1,21 @@ -/** Per-Session turn recorder: snapshot at turn start, diff and append at turn end. */ -import { realpath } from 'node:fs/promises' +/** Per-Session turn recorder: snapshot at turn start, diff at turn end, summary kept until disposal. */ +import { mkdtemp, realpath, rm } from 'node:fs/promises' import { homedir } from 'node:os' -import { relative, resolve } from 'node:path' +import { join, relative, resolve } from 'node:path' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace, type ObjectStoreOptions } from './git.ts' +import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts' import { argumentHunks, fileDiffsOf, hunkLineCounts } from './numstat.ts' import { canonicalPath, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, temporaryRoots, toPosix } from './paths.ts' -import type { WorkspaceChangedFile } from './types.ts' +import type { WorkspaceChangedFile, WorkspaceChangesSummary } from './types.ts' /** Facts shared by every recorder of one plugin instance. */ export interface RecorderEnvironment { /** Resolves to the runner, or null when git is unavailable and no turn records anything. */ git: Promise - /** Where snapshot objects live and how large one repository's store may grow. */ - objects: ObjectStoreOptions - /** Maximum files carried by one event. */ + /** Directory that receives each Session's snapshot object directory. */ + tempRoot: string + /** Maximum files carried by one summary. */ maxFiles: number /** Failure reporter; a failed turn records nothing and the next turn retries. */ warn: (message: string) => void @@ -56,9 +56,11 @@ function freshState(turn: number): TurnState { /** * Serializes one Session's git work: the turn-start snapshot, the turn-end - * snapshot with its diff, and the appended `workspace/changes` event. Tool - * execution waits for pending work so a snapshot never races a mutation. A - * working directory outside any repository records nothing. + * snapshot with its diff, and the appended `workspace/changes` event whose + * summary this recorder keeps. Snapshot objects live in a temporary directory + * owned by the recorder; disposal removes it together with the summaries. + * Tool execution waits for pending work so a snapshot never races a mutation. + * A working directory outside any repository records nothing. */ export class TurnRecorder { private chain: Promise = Promise.resolve() @@ -66,6 +68,10 @@ export class TurnRecorder { private state = freshState(0) /** The located repository, reused across turns once found; null keeps retrying each turn. */ private located: Located | null = null + /** Temporary directory holding this Session's snapshot objects, created with the first located repository. */ + private objectsDir: string | undefined + /** Summaries by the sequence of the event that announced them. */ + private readonly summaries = new Map() private readonly lifetime = new AbortController() constructor( @@ -142,9 +148,24 @@ export class TurnRecorder { return this.chain } - /** Abort queued git work; the recorder accepts nothing afterwards. */ - dispose(): void { + /** + * The summary announced by one `workspace/changes` event of this Session. + * @param seq - the event's sequence number. + * @returns the summary, or undefined for a sequence this recorder did not announce. + */ + summary(seq: number): WorkspaceChangesSummary | undefined { + return this.summaries.get(seq) + } + + /** + * Abort queued git work, forget every summary, and remove the snapshot objects. + * @returns once the temporary directory is gone. + */ + async dispose(): Promise { this.lifetime.abort() + this.summaries.clear() + await this.chain + if (this.objectsDir !== undefined) await rm(this.objectsDir, { recursive: true, force: true }) } private enqueue(task: (signal: AbortSignal) => Promise): Promise { @@ -171,7 +192,10 @@ export class TurnRecorder { const git = await this.env.git if (git === null) return null const cwd = await realpath(this.cwd) - const workspace = await locateGitWorkspace(git, cwd, this.env.objects, signal) + const workspace = await locateGitWorkspace(git, cwd, async () => { + this.objectsDir ??= await mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-objects-')) + return this.objectsDir + }, signal) if (workspace === null) return null this.located = { git, workspace, cwd, home: await canonicalPath(homedir()), temporaryRoots: await temporaryRoots() } return this.located @@ -206,12 +230,8 @@ export class TurnRecorder { const sorted = [...files.values()].sort(compareDisplay) // An empty list after an earlier in-turn record supersedes that record. if (sorted.length === 0 && state.recordedAfterSeq < 0) return - const event = this.session.append('workspace/changes', { - turn: state.turn, - files: sorted.slice(0, this.env.maxFiles), - total: sorted.length, - snapshot: { before, after }, - }) + const event = this.session.append('workspace/changes', { turn: state.turn }) + this.summaries.set(event.seq, { turn: state.turn, cwd: this.cwd, files: sorted.slice(0, this.env.maxFiles), total: sorted.length }) state.recordedAfterSeq = event.seq } } diff --git a/packages/deliverables/workspace-changes/src/types.ts b/packages/deliverables/workspace-changes/src/types.ts index 1092e8258c..46c0352351 100644 --- a/packages/deliverables/workspace-changes/src/types.ts +++ b/packages/deliverables/workspace-changes/src/types.ts @@ -1,4 +1,5 @@ -/** Durable per-turn workspace change summaries and their Session event. */ +/** Per-turn workspace change summaries, the Session event that announces them, and the Host service that serves them. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */ export interface WorkspaceChangedFile { @@ -18,21 +19,43 @@ export interface WorkspaceChangedFile { binary?: true } -/** Files changed during one top-level turn. */ -export interface WorkspaceChangesData { +/** Files changed during one top-level turn, kept on the Host until its Session is disposed. */ +export interface WorkspaceChangesSummary { /** The turn whose file changes this summary describes. */ turn: number + /** The Session working directory `path` values are relative to. */ + cwd: string /** Changed files in `display` order, capped at the plugin's `maxFiles`. */ files: WorkspaceChangedFile[] /** Complete changed-file count, including files omitted by the cap. */ total: number - /** Git tree object ids of the turn-start and turn-end working-tree snapshots. */ - snapshot: { before: string; after: string } +} + +/** Serves the summaries the recorder keeps for live Sessions. */ +export interface WorkspaceChanges { + /** + * The summary announced by one `workspace/changes` event. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. + */ + summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { - /** Files changed by a completed top-level turn; the latest event for one turn replaces earlier ones. */ - 'workspace/changes': WorkspaceChangesData + /** + * A completed top-level turn's changed files were summarized; the summary itself stays on the + * Host and is served by `workspaceChanges.summary` for the event's sequence while the Session + * lives. The latest event for one turn replaces earlier ones. + */ + 'workspace/changes': { turn: number } + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Per-turn changed-file summaries of live Sessions. */ + workspaceChanges: WorkspaceChanges } } diff --git a/packages/deliverables/workspace-changes/tests/git.spec.ts b/packages/deliverables/workspace-changes/tests/git.spec.ts index 1cde7db85d..be5a9e53e7 100644 --- a/packages/deliverables/workspace-changes/tests/git.spec.ts +++ b/packages/deliverables/workspace-changes/tests/git.spec.ts @@ -1,5 +1,5 @@ /** Git command bounds, snapshot recovery, and diff failure reporting. */ -import { chmod, mkdir, readFile, realpath, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -9,6 +9,9 @@ import { TurnRecorder } from '../src/recorder.ts' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { git, scratchDir } from './support.ts' +/** An object directory factory under a scratch root. */ +const objectsIn = (root: string) => () => Promise.resolve(join(root, 'objects')) + const cleanups: Array<() => Promise> = [] afterEach(async () => { for (const cleanup of cleanups.reverse()) await cleanup() @@ -55,7 +58,7 @@ describe('snapshots and diffs', () => { expect(() => git(cwd, 'merge', 'side')).toThrow() expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt') const { git: runnerGit } = await runner() - const workspace = await locateGitWorkspace(runnerGit, cwd, { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }, signal) + const workspace = await locateGitWorkspace(runnerGit, cwd, objectsIn(await scratchDir('dsh-git-store-', cleanups)), signal) expect(workspace?.root).toBe(await realpath(cwd)) const tree = await snapshotTree(runnerGit, workspace!, signal) expect(tree).toMatch(/^[0-9a-f]{40,64}$/) @@ -65,9 +68,9 @@ describe('snapshots and diffs', () => { it('fails loudly when the addressed repository cannot be written or diffed', async () => { const cwd = await scratchDir('dsh-git-broken-', cleanups) const { git: runnerGit } = await runner() - const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 } + const store = objectsIn(await scratchDir('dsh-git-store-', cleanups)) expect(await locateGitWorkspace(runnerGit, cwd, store, signal)).toBeNull() - const broken = { root: cwd, gitDir: join(cwd, 'missing'), objectsDir: join(cwd, 'missing-objects'), env: {} } + const broken = { root: cwd, gitDir: join(cwd, 'missing'), env: {} } await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in') await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed') expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set()) @@ -95,7 +98,7 @@ describe('repository edge cases', () => { await chmod(join(cwd, 'locked.txt'), 0o000) cleanups.push(() => chmod(join(cwd, 'locked.txt'), 0o644)) const { git: runnerGit } = await runner() - const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 } + const store = objectsIn(await scratchDir('dsh-git-store-', cleanups)) const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))! expect(await snapshotTree(runnerGit, workspace, signal)).toMatch(/^[0-9a-f]{40,64}$/) }) @@ -105,7 +108,7 @@ describe('repository edge cases', () => { git(cwd, 'init', '-q', '-b', 'main') await mkdir(join(cwd, '.git', 'index')) const { git: runnerGit } = await runner() - const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 } + const store = objectsIn(await scratchDir('dsh-git-store-', cleanups)) const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))! await expect(snapshotTree(runnerGit, workspace, signal)).rejects.toThrow() }) @@ -117,12 +120,12 @@ describe('repository edge cases', () => { const original = await readFile(config, 'utf8') await writeFile(config, original.replace(/repositoryformatversion = \d+/, 'repositoryformatversion = 99')) const { git: runnerGit } = await runner() - const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 } + const store = objectsIn(await scratchDir('dsh-git-store-', cleanups)) await expect(locateGitWorkspace(runnerGit, cwd, store, signal)).rejects.toThrow('git rev-parse failed') await writeFile(config, original) const blocked = join(cwd, 'store-file') await writeFile(blocked, 'not a directory') - await expect(locateGitWorkspace(runnerGit, cwd, { home: blocked, maxBytes: 1 }, signal)).rejects.toThrow() + await expect(locateGitWorkspace(runnerGit, cwd, objectsIn(blocked), signal)).rejects.toThrow() }) }) @@ -134,19 +137,18 @@ describe('TurnRecorder', () => { const warnings: string[] = [] let release!: (runner: GitRunner | null) => void const gate = new Promise((resolve) => { release = resolve }) - const env = { - git: gate, objects: { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }, - maxFiles: 10, warn: (m: string) => { warnings.push(m) }, - } + const tempRoot = await scratchDir('dsh-git-store-', cleanups) + const env = { git: gate, tempRoot, maxFiles: 10, warn: (m: string) => { warnings.push(m) } } const disposed = new TurnRecorder(session, cwd, env) disposed.start(1) await new Promise(resolve => setTimeout(resolve, 5)) - disposed.dispose() + const disposal = disposed.dispose() release(runnerGit) - await disposed.settled() + await disposal disposed.start(2) await disposed.settled() expect(warnings).toEqual([]) + expect(disposed.summary(1)).toBeUndefined() const { git: missing } = await runner(undefined, '/nonexistent/git-binary') const failing = new TurnRecorder(session, cwd, { ...env, git: Promise.resolve(missing) }) @@ -155,4 +157,24 @@ describe('TurnRecorder', () => { expect(warnings).toHaveLength(1) expect(warnings[0]).toContain('workspace-changes:') }) + + it('removes its snapshot objects on disposal and never creates them outside a repository', async () => { + const tempRoot = await scratchDir('dsh-git-store-', cleanups) + const { ctx, git: runnerGit } = await runner() + const env = { git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, warn: (m: string) => { throw new Error(m) } } + const plain = new TurnRecorder(ctx.sessions.create(SessionId('plain'), { meta: { cwd: tempRoot } }), tempRoot, env) + plain.start(1) + await plain.settled() + await plain.dispose() + const cwd = await scratchDir('dsh-recorder-repo-', cleanups) + git(cwd, 'init', '-q', '-b', 'main') + const repo = new TurnRecorder(ctx.sessions.create(SessionId('repo'), { meta: { cwd } }), cwd, env) + repo.start(1) + await repo.settled() + const [objects, ...others] = (await readdir(tempRoot)).filter(entry => entry.startsWith('dsh-workspace-changes-objects-')) + expect(others).toEqual([]) + expect(objects).toBeDefined() + await repo.dispose() + expect(await readdir(tempRoot)).toEqual([]) + }) }) diff --git a/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts b/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts index 1d3f87f9a3..d02a6fdd73 100644 --- a/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts +++ b/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts @@ -34,8 +34,6 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-subprocess-local'", "- name: '@deepseek-ai/dsh-workspace-changes'", - ' config:', - ` dshHome: ${JSON.stringify(join(root, 'home'))}`, '', ].join('\n')) context = new Context() @@ -73,10 +71,8 @@ describe('real Loader composition', () => { toolCall(session, 1, 'bash', { command: 'x' }) endTurn(session, 1) await context.waterfall('tools/pre-execute', { agent: { session } } as never, () => Promise.resolve(undefined as never)) - const [recorded, ...rest] = changes(session) + const [recorded, ...rest] = changes(context, session) expect(rest).toEqual([]) - expect(recorded).toMatchObject({ turn: 1, total: 1, files: [{ path: 'tracked.txt', display: 'tracked.txt', added: 1, deleted: 0 }] }) - expect(recorded!.snapshot.before).toMatch(/^[0-9a-f]+$/) - expect(recorded!.snapshot.after).toMatch(/^[0-9a-f]+$/) + expect(recorded).toEqual({ turn: 1, cwd, total: 1, files: [{ path: 'tracked.txt', display: 'tracked.txt', added: 1, deleted: 0 }] }) }) }) diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index 4e1648f8b8..f363ebe9dc 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/packages/deliverables/workspace-changes/tests/plugin.spec.ts @@ -1,5 +1,5 @@ /** The plugin records each top-level turn's file changes from real git snapshots. */ -import { mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -22,9 +22,8 @@ async function boot(config: Partial = {}) { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(LocalSubprocessRuntime) - const dshHome = config.dshHome ?? await scratchDir('dsh-workspace-changes-home-', cleanups) - const fiber = await ctx.plugin(WorkspaceChanges, { ...config, dshHome } as WorkspaceChanges.Config) - return { ctx, fiber, dshHome } + const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config) + return { ctx, fiber } } /** Loose objects the repository itself holds. */ @@ -49,7 +48,7 @@ describe('workspace-changes in a repository', () => { const cwd = await repository() await writeFile(join(cwd, 'b.txt'), 'x user\n') await writeFile(join(cwd, 'u.txt'), 'user untracked\n') - const { ctx, dshHome } = await boot() + const { ctx } = await boot() const repositoryObjects = looseObjects(cwd) const session = ctx.sessions.create(SessionId('repo'), { meta: { cwd } }) startTurn(session, 1) @@ -79,11 +78,11 @@ describe('workspace-changes in a repository', () => { endTurn(session, 1) await settle(ctx, session) - const [recorded, ...rest] = changes(session) + const events = session.snapshotEvents().filter(event => event.type === 'workspace/changes') + expect(events.map(event => event.data)).toEqual([{ turn: 1 }]) + const [recorded, ...rest] = changes(ctx, session) expect(rest).toEqual([]) - expect(recorded).toMatchObject({ turn: 1, total: 6 }) - expect(recorded!.snapshot.before).toMatch(/^[0-9a-f]{40,64}$/) - expect(recorded!.snapshot.after).toMatch(/^[0-9a-f]{40,64}$/) + expect(recorded).toMatchObject({ turn: 1, cwd, total: 6 }) expect(recorded!.files).toEqual([ { path: '.env', display: '.env', added: 2, deleted: 0 }, { path: '.env.gone', display: '.env.gone', added: 1, deleted: 0 }, @@ -95,42 +94,15 @@ describe('workspace-changes in a repository', () => { expect(git(cwd, 'status', '--porcelain').split('\n').filter(Boolean).sort()).toEqual([ ' M a.txt', ' M b.txt', '?? bin.dat', '?? new.txt', '?? sub/', '?? u.txt', ]) - // Snapshot objects live under the Harness home; the repository's own store is untouched. + // Snapshot objects live in the recorder's temporary directory; the repository's own store is untouched. expect(looseObjects(cwd)).toBe(repositoryObjects) - const stores = await readdir(join(dshHome, 'workspace-changes')) - expect(stores).toHaveLength(1) - const objects = await readdir(join(dshHome, 'workspace-changes', stores[0]!), { recursive: true }) - expect(objects.some(entry => /^[0-9a-f]{2}\/[0-9a-f]{38,}$/.test(entry.replaceAll('\\', '/')))).toBe(true) - }) - - it('discards a snapshot object store that outgrew its bound when the next Session locates the repository', async () => { - const cwd = await repository() - const { ctx, dshHome } = await boot({ objectStoreMaxBytes: 1 }) - const first = ctx.sessions.create(SessionId('bounded-1'), { meta: { cwd } }) - startTurn(first, 1) - await settle(ctx, first) - const [store] = await readdir(join(dshHome, 'workspace-changes')) - const marker = join(dshHome, 'workspace-changes', store!, 'marker') - await writeFile(marker, 'old store') - await writeFile(join(cwd, 'n.txt'), 'n\n') - toolCall(first, 1, 'bash', { command: 'x' }) - endTurn(first, 1) - await settle(ctx, first) - expect(changes(first)).toHaveLength(1) - // The located repository is reused within a Session, so the bound is checked once per Session. - startTurn(first, 2) - await settle(ctx, first) - expect((await stat(marker)).isFile()).toBe(true) - endTurn(first, 2) - const second = ctx.sessions.create(SessionId('bounded-2'), { meta: { cwd } }) - startTurn(second, 1) - await settle(ctx, second) - await expect(stat(marker)).rejects.toThrow() - await writeFile(join(cwd, 'm.txt'), 'm\n') - toolCall(second, 1, 'bash', { command: 'x' }) - endTurn(second, 1) - await settle(ctx, second) - expect(changes(second).at(-1)!.files.map(file => file.display)).toEqual(['m.txt']) + // Summaries are served by session and event sequence only while the Session lives. + const seq = events[0]!.seq + expect(ctx.workspaceChanges.summary(session.id, seq)).toBe(recorded) + expect(ctx.workspaceChanges.summary(session.id, seq + 1)).toBeUndefined() + expect(ctx.workspaceChanges.summary(SessionId('elsewhere'), seq)).toBeUndefined() + ctx.emit('session/disposed', session) + expect(ctx.workspaceChanges.summary(session.id, seq)).toBeUndefined() }) it('places files above the working directory and outside the repository by their display rule', async () => { @@ -148,7 +120,7 @@ describe('workspace-changes in a repository', () => { toolCall(session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' }) endTurn(session, 1, 'blocked') await settle(ctx, session) - const [recorded] = changes(session) + const [recorded] = changes(ctx, session) expect(recorded!.files).toEqual([ { path: join(await realpath(root), 'a.txt'), display: '../a.txt', added: 1, deleted: 3 }, { path: 'inner.txt', display: 'inner.txt', added: 1, deleted: 0 }, @@ -164,16 +136,16 @@ describe('workspace-changes in a repository', () => { const signal = new AbortController().signal startTurn(session, 1) await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal }) - expect(changes(session)).toEqual([]) + expect(changes(ctx, session)).toEqual([]) await writeFile(join(cwd, 'one.txt'), '1\n') toolCall(session, 1, 'bash', { command: 'x' }) await ctx.serial('agent/turn-stopping', { agent, turn: 7, signal }) await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal }) const inTurn = session.snapshotEvents().find(event => event.type === 'workspace/changes') - expect(inTurn?.data).toMatchObject({ turn: 1, total: 1 }) + expect(ctx.workspaceChanges.summary(session.id, inTurn!.seq)).toMatchObject({ turn: 1, total: 1 }) endTurn(session, 1) await settle(ctx, session) - expect(changes(session)).toHaveLength(1) + expect(changes(ctx, session)).toHaveLength(1) expect(session.snapshotEvents().find(event => event.type === 'turn/end')!.seq).toBeGreaterThan(inTurn!.seq) startTurn(session, 2) @@ -185,7 +157,7 @@ describe('workspace-changes in a repository', () => { toolCall(session, 2, 'bash', { command: 'steered' }) endTurn(session, 2) await settle(ctx, session) - const second = changes(session).filter(data => data.turn === 2) + const second = changes(ctx, session).filter(data => data.turn === 2) expect(second.map(data => data.files.map(file => file.path))).toEqual([['two.txt'], []]) expect(second[1]).toMatchObject({ total: 0 }) @@ -194,7 +166,7 @@ describe('workspace-changes in a repository', () => { toolCall(session, 3, 'read', { file_path: 'a.txt' }) endTurn(session, 3) await settle(ctx, session) - expect(changes(session).filter(data => data.turn === 3)).toEqual([]) + expect(changes(ctx, session).filter(data => data.turn === 3)).toEqual([]) }) it('keeps an interrupted turn’s record when the next turn starts before it settles', async () => { @@ -212,7 +184,7 @@ describe('workspace-changes in a repository', () => { toolCall(session, 2, 'bash', { command: 'x' }) endTurn(session, 2) await settle(ctx, session) - expect(changes(session).map(data => [data.turn, data.files.map(file => file.display)])).toEqual([[1, ['one.txt']], [2, ['two.txt']]]) + expect(changes(ctx, session).map(data => [data.turn, data.files.map(file => file.display)])).toEqual([[1, ['one.txt']], [2, ['two.txt']]]) }) it('caps the file list while reporting the complete count', async () => { @@ -226,7 +198,7 @@ describe('workspace-changes in a repository', () => { toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l1\n', newText: 'l1\n' }] } }) endTurn(session, 1) await settle(ctx, session) - const [recorded] = changes(session) + const [recorded] = changes(ctx, session) expect(recorded!.total).toBe(3) expect(recorded!.files.map(file => file.display)).toEqual(['c.txt', 'd.txt']) }) @@ -243,7 +215,7 @@ describe('workspace-changes in a repository', () => { toolCall(session, 1, 'bash', { command: 'x' }) endTurn(session, 1) await settle(ctx, session) - expect(changes(session)).toEqual([]) + expect(changes(ctx, session)).toEqual([]) const own = warn.mock.calls.map(call => String(call[0])).filter(message => message.startsWith('workspace-changes:')) expect(own).toHaveLength(1) expect(own[0]).toContain('missing-git') @@ -273,7 +245,7 @@ describe('workspace-changes without a repository', () => { }) endTurn(session, 1) await settle(ctx, session) - expect(changes(session)).toEqual([]) + expect(changes(ctx, session)).toEqual([]) expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([]) await expect(stat(join(cwd, '.git'))).rejects.toThrow() }) @@ -289,14 +261,14 @@ describe('workspace-changes without a repository', () => { toolCall(session, 1, 'bash', { command: 'x' }) endTurn(session, 1) await settle(ctx, session) - expect(changes(session)).toEqual([]) + expect(changes(ctx, session)).toEqual([]) startTurn(session, 2) await settle(ctx, session) await writeFile(join(cwd, 'two.txt'), '2\n') toolCall(session, 2, 'bash', { command: 'x' }) endTurn(session, 2) await settle(ctx, session) - expect(changes(session).map(data => data.files.map(file => file.display))).toEqual([['two.txt']]) + expect(changes(ctx, session).map(data => data.files.map(file => file.display))).toEqual([['two.txt']]) startTurn(session, 3) await settle(ctx, session) @@ -305,7 +277,7 @@ describe('workspace-changes without a repository', () => { toolCall(session, 3, 'bash', { command: 'x' }) endTurn(session, 3) await settle(ctx, session) - expect(changes(session).filter(data => data.turn === 3)).toEqual([]) + expect(session.snapshotEvents().filter(event => event.type === 'workspace/changes' && event.data.turn === 3)).toEqual([]) }) it('ignores subagent sessions and sessions without a working directory', async () => { @@ -322,7 +294,7 @@ describe('workspace-changes without a repository', () => { toolCall(session, 1, 'bash', { command: 'x' }) endTurn(session, 1) await settle(ctx, session) - expect(changes(session)).toEqual([]) + expect(changes(ctx, session)).toEqual([]) } await ctx.waterfall('tools/pre-execute', {} as never, () => Promise.resolve(undefined as never)) }) @@ -343,7 +315,7 @@ describe('workspace-changes without git', () => { endTurn(session, turn) await settle(ctx, session) } - expect(changes(session)).toEqual([]) + expect(changes(ctx, session)).toEqual([]) expect(info).toHaveBeenCalledTimes(1) expect(info.mock.calls[0]![0]).toContain('git is unavailable') }) diff --git a/packages/deliverables/workspace-changes/tests/support.ts b/packages/deliverables/workspace-changes/tests/support.ts index cd7d399d76..3e6b69fd8b 100644 --- a/packages/deliverables/workspace-changes/tests/support.ts +++ b/packages/deliverables/workspace-changes/tests/support.ts @@ -7,7 +7,7 @@ import type { Context } from '@deepseek-ai/cordis' import { ToolCallId, createAssistantMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-tools' -import type { WorkspaceChangesData } from '../src/types.ts' +import type { WorkspaceChangesSummary } from '../src/types.ts' let callNumber = 0 @@ -61,7 +61,10 @@ export async function settle(ctx: Context, session: Session): Promise { await ctx.waterfall('tools/pre-execute', { agent: { session } } as never, () => Promise.resolve(undefined as never)) } -/** Recorded change events of one session. */ -export function changes(session: Session): WorkspaceChangesData[] { - return session.snapshotEvents().filter(event => event.type === 'workspace/changes').map(event => event.data) +/** The summaries the Host still serves for one session's `workspace/changes` events, in log order. */ +export function changes(ctx: Context, session: Session): WorkspaceChangesSummary[] { + return session.snapshotEvents() + .filter(event => event.type === 'workspace/changes') + .map(event => ctx.workspaceChanges.summary(session.id, event.seq)) + .filter(summary => summary !== undefined) } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index bba999184d..14aa0f4b05 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2999,6 +2999,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'workspaceChanges', + summary: 'Serves the summaries the recorder keeps for live Sessions.', + description: 'Serves the summaries the recorder keeps for live Sessions.', + methods: [ + { + signature: 'summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined', + description: 'The summary announced by one `workspace/changes` event.', + parameters: [{ name: 'sessionId', description: 'the Session that appended the event.' }, { name: 'seq', description: 'the event\'s sequence number.' }], + returns: 'the summary, or undefined once its Session was disposed or when this Host never recorded it.', + }, + ], + }, { key: 'workspaceController', summary: 'Host service backing the generated `ctx.remote.workspace` namespace.', @@ -6646,6 +6659,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkspaceByteRange', declaration: 'export interface WorkspaceByteRange {\n readonly offset?: number;\n readonly length?: number;\n}', }, + { + name: 'WorkspaceChangedFile', + declaration: 'export interface WorkspaceChangedFile {\n path: string;\n display: string;\n added: number;\n deleted: number;\n binary?: true;\n}', + }, + { + name: 'WorkspaceChangesSummary', + declaration: 'export interface WorkspaceChangesSummary {\n turn: number;\n cwd: string;\n files: WorkspaceChangedFile[];\n total: number;\n}', + }, { name: 'WorkspaceCreateRequest', declaration: 'export interface WorkspaceCreateRequest {\n readonly path: string;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef0b9d6547..902b7c969a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5674,9 +5674,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 973ddc9a2a..7571e99014 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -128,6 +128,7 @@ export const SERVICE_PAGE: Record = { workspaceRegistry: 'workspace.md', workspaceController: 'workspace.md', workspaceFiles: 'workspace.md', + workspaceChanges: 'deliverables.md', terminalController: 'workspace.md', directoryPickerController: 'workspace.md', } @@ -252,6 +253,7 @@ export const EVENT_WALK_EXEMPTIONS: Record = { * appear on more than one page. */ export const LINK_MAP: Readonly> = { + WorkspaceChangesSummary: 'deliverables.md', BrowserUseProviderName: 'browser-use.md', ComputerUseProviderName: 'computer-use.md', Agent: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5860dd8d55..045b3ac162 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -226,6 +226,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.', }, + { + key: 'workspaceChanges', + pkg: 'workspace-changes', + title: 'Host per-turn changed-file summaries', + mode: 'core', + note: 'Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn.', + }, { key: 'terminalController', pkg: 'api-terminal-controller', diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 23c72f13d1..adf891d895 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -40,8 +40,8 @@ const LINK_MAP: Record = { StreamChunk: 'subsystems/llm-streaming.md', TokenUsage: 'subsystems/llm-streaming.md', TodoItem: 'subsystems/todo.md', + WorkspaceChangesSummary: 'subsystems/deliverables.md', PresentedFile: 'subsystems/deliverables.md', - WorkspaceChangesData: 'subsystems/deliverables.md', TurnTrigger: 'subsystems/session.md', TurnEndReason: 'subsystems/session.md', SessionTitleEventData: 'subsystems/session-title.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3bbabf8097..8baffa6cba 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2174,7 +2174,12 @@ }, { "doc": "docs/subsystems/deliverables.md", - "symbol": "WorkspaceChangesData", + "symbol": "WorkspaceChangesSummary", + "source": "packages/deliverables/workspace-changes/src/types.ts" + }, + { + "doc": "docs/subsystems/deliverables.md", + "symbol": "WorkspaceChanges", "source": "packages/deliverables/workspace-changes/src/types.ts" } ] diff --git a/snapshots/web/changed-files-turn/session.v3.jsonl b/snapshots/web/changed-files-turn/session.v3.jsonl index ae5dda692e..ca60e74614 100644 --- a/snapshots/web/changed-files-turn/session.v3.jsonl +++ b/snapshots/web/changed-files-turn/session.v3.jsonl @@ -38,5 +38,5 @@ {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"四件事全部完成:\n\n1. **`intro.md`** — 标题 `# 示例项目` 已改为 `# 项目说明`(仅改了标题行,正文未动)。\n2. **`src/util.ts`** — 新建,导出 `add(a: number, b: number): number`。\n3. **`app.local`** — 新建,内容为一行 `mode=demo`。\n4. **`notes.txt`** — 用 `bash` 追加成功,现有内容为 `start` / `done` 两行。\n\n说明:`intro.md` 的改动我按文件策略要求先读了一次原文件(只读该文件,未浏览目录),`notes.txt` 的追加则完全走 bash。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":151,"outputTokens":163,"totalTokens":9530,"cacheReadTokens":9216,"reasoningTokens":0},"stream":[{"type":"chunk","time":1789356772381,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789356772382,"index":0,"dt":[22,0,0,0,1,9,0,0,0,0,0,0,1,0,0,0,11,0,0,0,0,0,17,0,0,0,0,0,0,74,1,0,0,0,0,0,18,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,7,0,0,14,0,0,0,0,2,98,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,29,0,0,0,0,0,23,0,0,0,0,0,0,57,0,0,0,0,0,0,1,1,0,0,0,0,54,0,2,0,0,0,0,0,0,0,0,27,0,0,0,0,0,30,0,0,0,43,1,0,0,0,0,0,28,0,2],"texts":["四","件事","全部","完成",":\n\n","1","."," **","`","intro",".md","`","**"," —"," ","标题"," `","#"," ","示例","项目","`"," ","已","改为"," `","#"," ","项目","说明","`","(","仅","改了","标题","行",",","正文","未","动",")。\n","2","."," **","`","src","/util",".ts","`","**"," —"," ","新建",",","导出"," `","add","(a",":"," number",","," b",":"," number","):"," number","`","。\n","3","."," **","`","app",".local","`","**"," —"," ","新建",",","内容","为","一行"," `","mode","=d","emo","`","。\n","4","."," **","`","notes",".txt","`","**"," —"," ","用"," `","bash","`"," ","追加","成功",",","现有","内容","为"," `","start","`"," /"," `","done","`"," ","两","行","。\n\n","说明",":","`","intro",".md","`"," ","的","改动","我","按","文件","策略","要求","先","读","了一次","原","文件","(","只","读","该","文件",",","未","浏览","目录","),","`","notes",".txt","`"," ","的","追加","则","完全","走"," bash","。"]},{"type":"chunk","time":1789356772975,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"四件事全部完成:\n\n1. **`intro.md`** — 标题 `# 示例项目` 已改为 `# 项目说明`(仅改了标题行,正文未动)。\n2. **`src/util.ts`** — 新建,导出 `add(a: number, b: number): number`。\n3. **`app.local`** — 新建,内容为一行 `mode=demo`。\n4. **`notes.txt`** — 用 `bash` 追加成功,现有内容为 `start` / `done` 两行。\n\n说明:`intro.md` 的改动我按文件策略要求先读了一次原文件(只读该文件,未浏览目录),`notes.txt` 的追加则完全走 bash。"}}},{"type":"chunk","time":1789356772975,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":163,"totalTokens":9530,"cacheReadTokens":9216,"reasoningTokens":0}}},{"type":"chunk","time":1789356772975,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} -{"type":"workspace/changes","data":{"turn":1,"files":[{"path":"app.local","display":"app.local","added":1,"deleted":0},{"path":"intro.md","display":"intro.md","added":1,"deleted":1},{"path":"notes.txt","display":"notes.txt","added":1,"deleted":0},{"path":"src/util.ts","display":"src/util.ts","added":3,"deleted":0}],"total":4,"snapshot":{"before":"b8f9d36e04a50d565d1d5e80d79a2c8d37a7fc47","after":"6bf94696ee1aeb7872e3c4365f51dbef6d1c1cb7"}}} +{"type":"workspace/changes","data":{"turn":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/tsconfig.host.json b/tsconfig.host.json index 26e1427e1c..33456cd36a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -92,7 +92,6 @@ "apps/web/tests/feedback-release.e2e.ts", "apps/web/tests/goal-command-presentation.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", - "apps/web/tests/changed-files.e2e.ts", "apps/web/tests/changed-files-turn.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", From 620c52ca666f3bce7c64d57d3540707c2ef1fb46 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 18:32:48 +0800 Subject: [PATCH 19/37] test(web): give the clickable-links gallery its own native-open overlay --- apps/web/tests/clickable-links-gallery.e2e.ts | 2 +- apps/web/tests/clickable-links-gallery.overlay.yml | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/clickable-links-gallery.overlay.yml diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index a5d8101278..263a23d9bc 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -41,7 +41,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui // The golden holds the show-in-folder affordance; pin the native-opener // capability so headless Linux CI and desktop developer hosts expose the same // UI branch (same pin as produced-files.e2e.ts, whose overlay this shares). -const OVERLAY = fileURLToPath(new URL('./changed-files.overlay.yml', import.meta.url)) +const OVERLAY = fileURLToPath(new URL('./clickable-links-gallery.overlay.yml', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'clickable-links-gallery-web-e2e' const DONE = 'LINK_GALLERY_DONE' diff --git a/apps/web/tests/clickable-links-gallery.overlay.yml b/apps/web/tests/clickable-links-gallery.overlay.yml new file mode 100644 index 0000000000..d8adc80e3b --- /dev/null +++ b/apps/web/tests/clickable-links-gallery.overlay.yml @@ -0,0 +1,9 @@ +# The gallery asserts native open actions without launching them. Pin the +# capability so headless Linux CI and desktop developer hosts expose the same +# UI branch; platform opener behavior belongs to the Host unit tests. +- id: session-controller + config: + nativeOpen: true +- id: settings-controller + config: + nativeOpen: true From 2a77c59a49d762323073e786b7eb65b33e823486 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 15 Sep 2026 15:14:35 +0800 Subject: [PATCH 20/37] test(web): refresh the changed-files golden for master's edit-card counts and desktop copy --- snapshots/web/changed-files-turn/ui.expected.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index 7da2edbc96..00baa488de 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -29,12 +29,12 @@ - img - img - text: 思考 Now do all four actions. Edit intro.md, write src/util.ts, write app.local, bash append. -- button "编辑 intro.md +3 -3": +- button "编辑 intro.md +1 -1": - img - img - text: 编辑 - button "intro.md" - - text: +3 -3 + - text: +1 -1 - button "写入 src/util.ts +3 -0": - img - img @@ -113,7 +113,7 @@ - button "展开全部 4 个改动文件": - text: 全部 4 个文件 - img -- text: 此主机没有可用的桌面,无法打开文件或文件夹 +- text: 此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览 - button "在侧边栏预览 intro.md" - text: intro.md 标题已改为「项目说明」 - button "在侧边栏打开 intro.md": 打开 From c48f4203d9b6cc3d85dcd15c3e64ded4a37b8be0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 15 Sep 2026 15:44:11 +0800 Subject: [PATCH 21/37] feat(workspace-changes): summarize file-tool edits outside a git repository A working directory outside any repository, or a Host without git, no longer loses the card: the recorder skips the snapshots and builds the summary from the hunks the file tools persisted, with the working directory as the workspace, so shell edits are the only gap. The sidebar e2e scopes its chip clicks to the prose mention now that the card offers the same open, and the present-svg and permission-policy scenarios gain the card their write calls earn. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 6 +- .../2026-09-11-turn-changed-files-card.zh.md | 6 +- apps/web/tests/present-svg.e2e.ts | 4 +- apps/web/tests/sidebar-right.e2e.ts | 30 ++++--- docs/subsystems/deliverables.i18n.yaml | 4 +- docs/subsystems/deliverables.md | 2 +- docs/subsystems/deliverables.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../workspace-changes/README.i18n.yaml | 4 +- .../deliverables/workspace-changes/README.md | 6 +- .../workspace-changes/README.zh.md | 6 +- .../workspace-changes/src/index.ts | 6 +- .../workspace-changes/src/recorder.ts | 85 +++++++++++-------- .../workspace-changes/tests/plugin.spec.ts | 28 ++++-- .../session.v3.jsonl | 15 ++-- snapshots/web/present-svg/session.v3.jsonl | 7 +- snapshots/web/present-svg/ui.expected.md | 4 + 20 files changed, 139 insertions(+), 88 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index 30db80a4bb..006cc6acb4 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: 9e5ca9689e2af3a650f596bdd8da980396ddb4d4 -2026-09-11-turn-changed-files-card.zh.md: 4af94d62eb434ee244a80f63290c256d0960c826 +2026-09-11-turn-changed-files-card.md: b8c6ad42cb7baab26cdc9d8bcda2b4ec672afe12 +2026-09-11-turn-changed-files-card.zh.md: 4687b74a18c00b2e5b30cef42ceefb54c912c2a0 diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index 9e5ca9689e..b8c6ad42cb 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -14,7 +14,7 @@ The Host [workspace-changes](../../../../packages/deliverables/workspace-changes The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a temporary object directory owned by the Session while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects; keeping every captured byte outside the workspace follows the workspace change journal POC (#2973) and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it. -Git is the default executable on `PATH`; no environment plugin is consulted. Only a working directory inside a git repository is recorded; outside any repository, or without git, the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. +Git is the default executable on `PATH`; no environment plugin is consulted. Outside any repository, or without git, no snapshot is taken and the summary lists the file-tool edits alone, with the working directory as the workspace, so the card still appears but misses shell edits. Nested repositories and submodules are gitlinks and are not descended into. Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, or from the call's arguments when a result persists none, as a `write` that creates a file and every `str_replace_editor` mutation do; no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. @@ -32,7 +32,7 @@ The log deliberately carries nothing but the turn number. Summaries, snapshot tr **A git tag or `stash create` per turn** leaves refs in the user's repository or omits untracked files; a tree written through a private index does neither. -**A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; the card is simply absent until git exists. +**A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; until git exists the card lists file-tool edits only. **Recording the pre-edit file content at first touch** would make hunk counts exact and enable full-file diffs for uncovered files, but requires the file tools to hand their pre-read content to the recorder; the persisted hunks already carry the before and after text of each edit for that purpose. @@ -42,7 +42,7 @@ The log deliberately carries nothing but the turn number. Summaries, snapshot tr **An environment-provider seam for locating git** was raised by the team but not settled; the plugin uses `PATH` and keeps its lookup in one place. -**A shadow repository for working directories outside any repository** — a git directory under the Harness home with the work tree pointing at the working directory — would give those users the card without adding a `.git`, but a shadow repository has no `.gitignore`, and a configured exclude list cannot reliably keep build outputs, caches, and dependency trees out of every project layout. It is deferred until that exclude policy is settled; the recorder already treats the repository as an input, so adding the tier changes only where the snapshot goes. +**A shadow repository for working directories outside any repository** — a git directory under the Harness home with the work tree pointing at the working directory — would add shell edits to those users' card without adding a `.git`, but a shadow repository has no `.gitignore`, and a configured exclude list cannot reliably keep build outputs, caches, and dependency trees out of every project layout. It is deferred until that exclude policy is settled; the recorder already treats the repository as an input, so adding the tier changes only where the snapshot goes. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index 4af94d62eb..4687b74a18 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -14,7 +14,7 @@ Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-chan 记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两者都写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 挂接,因此用户仓库不会多出任何对象;把捕获的每个字节都放在工作区外学自工作区变更日志 POC(#2973),而且因为 stat 缓存在 index 里,它对耗时没有可测量的影响。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、对象、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。 -git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。 +git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。不在任何仓库内或没有 git 时,不做快照,摘要只列文件工具的编辑,并以工作目录作为工作区,因此卡片仍会出现,但缺少 shell 的改动。嵌套仓库和 submodule 是 gitlink,不会深入。 快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,结果没有持久化 hunk 时取调用参数,新建文件的 `write` 和 `str_replace_editor` 的每种修改都是这种情况;不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 @@ -32,7 +32,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 **每轮打一个 git tag 或 `stash create`**会在用户仓库里留下 ref,或者遗漏未跟踪文件;通过私有 index 写出的树两者都不会发生。 -**为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片直接不显示。 +**为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片只列文件工具的编辑。 **在首次触碰时记录编辑前的文件内容**能让 hunk 计数精确并为未覆盖文件提供整文件 diff,但要求文件工具把预先读取的内容交给记录器;持久化的 hunk 已经为此带上了每次编辑的前后文本。 @@ -42,7 +42,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 **用于定位 git 的环境提供者接缝**由团队提出但尚未定案;插件使用 `PATH` 并把查找收敛在一处。 -**为不在任何仓库内的工作目录建影子仓库**,即把 git 目录放在 Harness home 下、工作树指向工作目录,可以让这些用户在不多出 `.git` 的情况下看到卡片,但影子仓库没有 `.gitignore`,一份配置的排除列表无法在所有项目布局里可靠地挡住构建产物、缓存和依赖目录。它暂缓到排除策略定下来为止;记录器已经把仓库当作输入,加这一档只改变快照写到哪里。 +**为不在任何仓库内的工作目录建影子仓库**,即把 git 目录放在 Harness home 下、工作树指向工作目录,可以让这些用户在不多出 `.git` 的情况下也看到 shell 改动,但影子仓库没有 `.gitignore`,一份配置的排除列表无法在所有项目布局里可靠地挡住构建产物、缓存和依赖目录。它暂缓到排除策略定下来为止;记录器已经把仓库当作输入,加这一档只改变快照写到哪里。 ## Consequences diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index ad56cbe48b..8273a72d02 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -106,8 +106,8 @@ describe('web e2e: requested SVG is explicitly delivered', () => { await card.waitFor({ state: 'visible' }) expect(await card.count()).toBe(1) expect(await page.getByText('产物', { exact: true }).count()).toBe(0) - // The scaffold workspace is not a git repository, so no changed-files card accompanies the delivery. - expect(await page.locator('[data-changed-files]').count()).toBe(0) + // The scaffold workspace is not a git repository, so the changed-files card lists the written SVG from the write call alone. + expect(await page.locator('[data-changed-files]').count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 86843b95e3..44d4d02e82 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -186,6 +186,15 @@ async function setPanelWidth(page: Page, target: number): Promise { } /** Tab titles inside one container, in strip order. */ +/** + * The closing prose's inline-code mention of the seeded file. The changed-files + * card below the prose offers the same open under the same accessible name, so + * the first match in document order is the prose's. + */ +function proseChip(root: Page): Locator { + return root.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).first() +} + async function tabTitles(root: Locator): Promise { return await root.locator('[data-dockkit-tab-title]').allInnerTexts() } @@ -734,7 +743,7 @@ describe('web e2e: shipped right Sidebar', () => { // The product's own entry point: the closing prose's file mention. It // reaches the Sidebar through openFile → ctx.sidebarRight.openResource, and the // text type claims the address. - const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME} in sidebar` }) + const chip = proseChip(page) await chip.click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) @@ -756,11 +765,12 @@ describe('web e2e: shipped right Sidebar', () => { await shot(page, '06-produced-chip-to-preview') // The directory scenario's V1 behaviour, asserted in the shipped product: - // there is no folder affordance at all. `openFile('.')` would name a - // directory, which a text preview correctly refuses, and the native opener - // it used to reach is gone — so the row offers nothing rather than a - // button that always fails. - expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0) + // the prose mention offers no folder affordance. `openFile('.')` would + // name a directory, which a text preview correctly refuses. The only + // folder action on the page is the changed-files card's header, and only + // when the Host has a desktop. + const folders = page.getByRole('button', { name: /folder/i }) + expect(await folders.count()).toBe(await page.locator('[data-changed-files]').getByRole('button', { name: /folder/i }).count()) // Split, then dock-drag: the kit's gestures drive the store's actions. await panes.first().locator('[data-dockkit-split-button]').click() @@ -819,7 +829,7 @@ describe('web e2e: shipped right Sidebar', () => { const column = fx.locator('[data-rightbar-col]') await ensureExpanded(fx, column) await width(column) - await fx.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await proseChip(fx).click() await column.locator('[data-textpreview-state="text"]').waitFor({ timeout: 15_000 }) const wrap = column.locator('[data-textpreview-tool="wrap"]') expect(await wrap.getAttribute('aria-pressed')).toBe('true') @@ -888,7 +898,7 @@ describe('web e2e: shipped right Sidebar', () => { // leave it standing, since a pane emptied by a move is dropped. const first = panes.first() const strip = first.locator('[data-dockkit-strip]') - await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await proseChip(page).click() await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME]) const order = await tabTitles(first) // The insertion index is measured against chip midpoints, not strip width. @@ -962,7 +972,7 @@ describe('web e2e: shipped right Sidebar', () => { const column = await resetSidebar(page) const panes = column.locator('[data-dockkit-pane]') expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1) - await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await proseChip(page).click() await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME]) await panes.first().locator('[data-dockkit-split-button]').click() await expect.poll(async () => await panes.count()).toBe(2) @@ -998,7 +1008,7 @@ describe('web e2e: shipped right Sidebar', () => { // sample file, close the guide (an ordinary close with two tabs), then // close the file: the column collapses in the same gesture, and the // settle rule reseeds the current default, so reopening shows Start. - await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await proseChip(page).click() await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) await column.locator('[data-dockkit-tab]').first().hover() await column.locator('[data-dockkit-tab-close]').first().click() diff --git a/docs/subsystems/deliverables.i18n.yaml b/docs/subsystems/deliverables.i18n.yaml index 3ff1ae458a..c50a3762e3 100644 --- a/docs/subsystems/deliverables.i18n.yaml +++ b/docs/subsystems/deliverables.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/subsystems/deliverables.md -deliverables.md: 22b51978fddc44bc40083bcbd501aec19a380937 -deliverables.zh.md: 67774eb4ba8c91e030ef20e228557276ff73d48b +deliverables.md: 6542d402a21e064f5b4d2f6223b0a22534fe7fd3 +deliverables.zh.md: f2b4490a66ee0f068af33ae00839b1ebb23e9ea8 diff --git a/docs/subsystems/deliverables.md b/docs/subsystems/deliverables.md index 22b51978fd..6542d402a2 100644 --- a/docs/subsystems/deliverables.md +++ b/docs/subsystems/deliverables.md @@ -2,7 +2,7 @@ English | [中文](deliverables.zh.md) -What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end, announced by a log-only event and served by a Host service while the Session lives. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md). +What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end (or, outside a repository, from the file tools' persisted hunks alone), announced by a log-only event and served by a Host service while the Session lives. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md). Sources: [`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts), [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) diff --git a/docs/subsystems/deliverables.zh.md b/docs/subsystems/deliverables.zh.md index 67774eb4ba..f2b4490a66 100644 --- a/docs/subsystems/deliverables.zh.md +++ b/docs/subsystems/deliverables.zh.md @@ -2,7 +2,7 @@ [English](deliverables.md) | 中文 -记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出,用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。 +记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出(不在仓库内时只由文件工具持久化的 hunk 得出),用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。 源码:[`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts)、[`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 27b3e64560..97fca61f0b 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/ui-deliverables/README.md -README.md: 340a4c8a0dc6e3d99e71973b5ecfe13560716422 -README.zh.md: 4decf7f247fe33c5b8d4ed92f2f05925e3897df0 +README.md: 5875fb131b094cf2e9bee3ec110bbd8bcdfba8e7 +README.zh.md: e3327a881cd15d13c6171a40b2cb6a537147098e diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 340a4c8a0d..5875fb131b 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -25,7 +25,7 @@ This package renders the changed-files card a finished turn ends with — the fi ## Use this package -Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../deliverables/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a served summary — a workspace outside any git repository, no git on the Host, the plugin composed out, or a Host restarted since the turn ran — the card is absent and only deliveries and prose links remain. +Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](../../deliverables/workspace-changes/README.md) plugin; a finished turn then ends with the changed-files card between the closing message's body and its action footer. Without a served summary — a turn that changed no file, the plugin composed out, or a Host restarted since the turn ran — the card is absent and only deliveries and prose links remain; outside a git repository the summary lists file-tool edits only. ### Explicit deliveries diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 4decf7f247..e3327a881c 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../deliverables/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有可提供的摘要时——工作区不在任何 git 仓库内、Host 上没有 git、该插件被组合出去,或该轮之后 Host 重启过——卡片不出现,只保留交付卡片与正文链接。 +与 `ui-conversation` 和 Host 侧的 [workspace-changes](../../deliverables/workspace-changes/README.zh.md) 插件一起挂载本插件;已完成轮次随即以改动文件卡片收尾,位于收尾消息正文与其动作页脚之间。没有可提供的摘要时——本轮没有改动任何文件、该插件被组合出去,或该轮之后 Host 重启过——卡片不出现,只保留交付卡片与正文链接;工作区不在 git 仓库内时摘要只列文件工具的编辑。 ### 显式交付 diff --git a/packages/deliverables/workspace-changes/README.i18n.yaml b/packages/deliverables/workspace-changes/README.i18n.yaml index f0f99520f7..da91f3fdc5 100644 --- a/packages/deliverables/workspace-changes/README.i18n.yaml +++ b/packages/deliverables/workspace-changes/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/deliverables/workspace-changes/README.md -README.md: cf0bc63290f3e4820e21c966642ed7c28c441cc3 -README.zh.md: 3d551c79f62c3ab42bbaf0fc3f1467b783563676 +README.md: 82106aa6c7fa7b812a228fc80f693d9fbee1ab5b +README.zh.md: 31503458242cf42a93483b2a81537058516719b5 diff --git a/packages/deliverables/workspace-changes/README.md b/packages/deliverables/workspace-changes/README.md index cf0bc63290..82106aa6c7 100644 --- a/packages/deliverables/workspace-changes/README.md +++ b/packages/deliverables/workspace-changes/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This plugin summarizes which files each top-level turn changed, with per-file added and deleted line counts. It snapshots the working tree with git when a turn starts and when it ends, diffs the two snapshots, and adds the file-tool edits that git does not cover. The Session log receives only a `workspace/changes` event naming the turn; the summary itself stays on the Host, served through the `workspaceChanges` service until the Session is disposed. Only a working directory inside a git repository is recorded. The Web changed-files card renders the served summary; the model never sees it. +This plugin summarizes which files each top-level turn changed, with per-file added and deleted line counts: git snapshots of the working tree at turn start and turn end are diffed, and the file-tool edits git does not cover are added. Outside a git repository, or without git, the summary lists the file-tool edits only. The Session log receives one `workspace/changes` event naming the turn; the summary stays on the Host, served through the `workspaceChanges` service until the Session is disposed. The Web changed-files card renders it; the model never sees it. ## Table of Contents @@ -39,7 +39,7 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the | `outputMaxBytes` | `8388608` | Bytes of git output retained per command; a larger diff listing abandons the record | | `maxFiles` | `500` | Maximum files carried by one summary; `total` still reports the complete count | -Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots are written through a private index into a temporary object directory owned by the Session, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. Session disposal removes the directory. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once. +Every Session with a working directory and no subagent origin is recorded; subagent Sessions are not. Snapshots are written through a private index into a temporary object directory owned by the Session, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. Session disposal removes the directory. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. A working directory outside any git repository takes no snapshots. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — no repository is located either, and the plugin logs that once. Either way the summary lists the file-tool edits alone, as described next, with the working directory as the workspace; shell edits are absent. Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results, or from the call's own arguments when the result persists none — a `write` that creates a file and every `str_replace_editor` mutation: files matching an ignore pattern and files outside the repository. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded. @@ -87,7 +87,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected. - Two git features still write into the repository's own git directory during a snapshot: `core.splitIndex` writes `sharedindex.*` files, and git-lfs runs its clean filter on changed files and stores their objects under `.git/lfs`. - git 2.13 or later is required for `rev-parse --absolute-git-dir`; an unsupported repository format or another git failure abandons the turn with a warning rather than being treated as a plain directory. - Edits the user makes during a turn are attributed to that turn. -- A working directory outside any git repository has no card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably. +- A working directory outside any git repository lists file-tool edits only, so shell edits are missing from its card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably. - Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only. - Windows paths keep native separators in `path`; `display` is always slash-separated. diff --git a/packages/deliverables/workspace-changes/README.zh.md b/packages/deliverables/workspace-changes/README.zh.md index 3d551c79f6..3150345824 100644 --- a/packages/deliverables/workspace-changes/README.zh.md +++ b/packages/deliverables/workspace-changes/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本插件汇总每个顶层轮次改动了哪些文件、每个文件的增删行数。它在轮次开始和结束时用 git 对工作树做快照,比较两次快照,并补上 git 覆盖不到的文件工具编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要本身留在 Host 上,通过 `workspaceChanges` 服务提供,直到 Session 释放。只有位于 git 仓库内的工作目录会被记录。Web 的改动文件卡片渲染提供的摘要;模型看不到它。 +本插件汇总每个顶层轮次改动了哪些文件、每个文件的增删行数:比较轮次开始和结束时的 git 工作树快照,再补上 git 覆盖不到的文件工具编辑。不在 git 仓库内或没有 git 时,摘要只列文件工具的编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要留在 Host 上,通过 `workspaceChanges` 服务提供,直到 Session 释放。Web 的改动文件卡片渲染它;模型看不到它。 ## 目录 @@ -39,7 +39,7 @@ kind: "package-reference" | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 | | `maxFiles` | `500` | 单份摘要携带的最大文件数;`total` 仍报告完整数量 | -工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 +有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。不在任何 git 仓库内的工作目录不做快照。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——同样定位不到仓库,插件记录一次日志。两种情况下摘要都只列下文所述的文件工具编辑,并以工作目录作为工作区;shell 的改动不会出现。 文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入,结果没有持久化 hunk 时则取调用自身的参数,也就是新建文件的 `write` 和 `str_replace_editor` 的每一种修改:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。 @@ -87,7 +87,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ - 有两个 git 功能在快照期间仍会写入仓库自己的 git 目录:`core.splitIndex` 会写 `sharedindex.*` 文件,git-lfs 会对改动文件运行 clean 过滤器并把对象存到 `.git/lfs` 下。 - 需要 git 2.13 或更高版本以支持 `rev-parse --absolute-git-dir`;不支持的仓库格式或其他 git 失败会带着警告放弃本轮,而不是被当成普通目录。 - 用户在轮次进行中自己做的编辑会被算到该轮。 -- 不在任何 git 仓库内的工作目录没有卡片;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。 +- 不在任何 git 仓库内的工作目录只列文件工具的编辑,卡片里因此没有 shell 改动;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。 - 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。 - Windows 路径在 `path` 中保留原生分隔符;`display` 始终用斜杠分隔。 diff --git a/packages/deliverables/workspace-changes/src/index.ts b/packages/deliverables/workspace-changes/src/index.ts index e686d4c448..23a88e7f74 100644 --- a/packages/deliverables/workspace-changes/src/index.ts +++ b/packages/deliverables/workspace-changes/src/index.ts @@ -3,8 +3,8 @@ * snapshots taken at turn start and turn end plus the hunks file tools persist * for paths git does not cover. Each summary is announced by a `workspace/changes` * Session event that carries only the turn number and is served through the - * `workspaceChanges` service until the Session is disposed. Only a working - * directory inside a git repository is recorded. + * `workspaceChanges` service until the Session is disposed. Outside a git + * repository, or without git, the summary lists file-tool edits only. */ import { homedir, tmpdir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' @@ -103,7 +103,7 @@ export function apply(ctx: Context, config: Config): void { const gitRunner = (): Promise => { runner ??= resolveGit(ctx, lifetime.signal).then((executable) => { if (executable === null) { - ctx.logger.info('workspace-changes: git is unavailable; turn file changes are not recorded') + ctx.logger.info('workspace-changes: git is unavailable; only file-tool edits are summarized') return null } return new GitRunner(ctx.subprocess, executable, { timeoutMs: config.timeoutMs, outputMaxBytes: config.outputMaxBytes }) diff --git a/packages/deliverables/workspace-changes/src/recorder.ts b/packages/deliverables/workspace-changes/src/recorder.ts index 1e8dda04bd..b2c6afe223 100644 --- a/packages/deliverables/workspace-changes/src/recorder.ts +++ b/packages/deliverables/workspace-changes/src/recorder.ts @@ -21,23 +21,29 @@ export interface RecorderEnvironment { warn: (message: string) => void } -/** Everything a located repository needs, resolved once per Session. */ -interface Located { - git: GitRunner - workspace: GitWorkspace +/** Canonical paths every comparison and display uses, resolved once per Session. */ +interface Paths { /** Canonical working directory; git reports symlink-resolved paths, so every comparison uses that form. */ cwd: string /** Canonical home directory abbreviated as `~` in display paths. */ home: string - /** Temporary roots whose files never enter a summary. */ + /** Temporary roots whose files outside the workspace never enter a summary. */ temporaryRoots: readonly string[] } -interface Baseline extends Located { tree: string } +/** The repository enclosing the working directory and the runner that snapshots it. */ +interface Repository { + git: GitRunner + workspace: GitWorkspace +} + +/** The turn-start snapshot of a located repository. */ +interface Baseline extends Repository { tree: string } /** Everything one turn accumulates; a new turn gets a new object so queued work for an older turn keeps its own. */ interface TurnState { readonly turn: number + /** Set once the turn-start snapshot exists; a turn without one summarizes file-tool hunks only. */ baseline: Baseline | null /** Hunks derived from each mutation call's arguments, or null for a call that changes no file. */ readonly calls: Map @@ -60,14 +66,17 @@ function freshState(turn: number): TurnState { * summary this recorder keeps. Snapshot objects live in a temporary directory * owned by the recorder; disposal removes it together with the summaries. * Tool execution waits for pending work so a snapshot never races a mutation. - * A working directory outside any repository records nothing. + * A working directory outside any repository, or a Host without git, gets no + * snapshot; its summary lists the files the file tools changed. */ export class TurnRecorder { private chain: Promise = Promise.resolve() /** The open turn; before the first `turn/start` it is an empty placeholder no event can match. */ private state = freshState(0) + /** Canonical paths, resolved by the first turn. */ + private paths: Paths | undefined /** The located repository, reused across turns once found; null keeps retrying each turn. */ - private located: Located | null = null + private repository: Repository | null = null /** Temporary directory holding this Session's snapshot objects, created with the first located repository. */ private objectsDir: string | undefined /** Summaries by the sequence of the event that announced them. */ @@ -88,10 +97,11 @@ export class TurnRecorder { const state = freshState(turn) this.state = state void this.enqueue(async (signal) => { - const located = await this.locate(signal) - if (located === null) return - const tree = await snapshotTree(located.git, located.workspace, signal) - state.baseline = { ...located, tree } + this.paths ??= { cwd: await realpath(this.cwd), home: await canonicalPath(homedir()), temporaryRoots: await temporaryRoots() } + const repository = await this.locate(this.paths.cwd, signal) + if (repository === null) return + const tree = await snapshotTree(repository.git, repository.workspace, signal) + state.baseline = { ...repository, tree } }) } @@ -186,46 +196,53 @@ export class TurnRecorder { if (!this.lifetime.signal.aborted) this.env.warn(`workspace-changes: ${String(error)}`) } - /** The repository for this Session together with the canonical paths every comparison uses, located once. */ - private async locate(signal: AbortSignal): Promise { - if (this.located !== null) return this.located + /** The repository enclosing the working directory, located once; null keeps retrying each turn. */ + private async locate(cwd: string, signal: AbortSignal): Promise { + if (this.repository !== null) return this.repository const git = await this.env.git if (git === null) return null - const cwd = await realpath(this.cwd) const workspace = await locateGitWorkspace(git, cwd, async () => { this.objectsDir ??= await mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-objects-')) return this.objectsDir }, signal) if (workspace === null) return null - this.located = { git, workspace, cwd, home: await canonicalPath(homedir()), temporaryRoots: await temporaryRoots() } - return this.located + this.repository = { git, workspace } + return this.repository } private async record(state: TurnState, signal: AbortSignal): Promise { - if (state.baseline === null || state.lastToolResultSeq < 0) return + const paths = this.paths + if (paths === undefined || state.lastToolResultSeq < 0) return state.attemptedAfterSeq = state.lastToolResultSeq - const baseline = state.baseline - const { git, workspace, tree: before, cwd } = baseline - const root = workspace.root - const after = await snapshotTree(git, workspace, signal) + const { baseline } = state + // Without a snapshot the working directory itself bounds the workspace. + const root = baseline?.workspace.root ?? paths.cwd const files = new Map() - for (const entry of await diffTrees(git, workspace, before, after, signal)) { - const absolute = resolve(root, entry.path) - files.set(absolute, changedFile(baseline, absolute, entry)) + if (baseline !== null) { + const after = await snapshotTree(baseline.git, baseline.workspace, signal) + for (const entry of await diffTrees(baseline.git, baseline.workspace, baseline.tree, after, signal)) { + const absolute = resolve(root, entry.path) + files.set(absolute, changedFile(paths, root, absolute, entry)) + } } // File-tool hunks by canonical absolute path, for the files snapshots do not cover. const hunks = new Map() for (const [path, list] of state.hunks) { - const absolute = await canonicalPath(resolve(cwd, path)) + const absolute = await canonicalPath(resolve(paths.cwd, path)) if (!files.has(absolute)) hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list]) } const workTreePath = (absolute: string): string => toPosix(relative(root, absolute)) - const inRepository = [...hunks.keys()].filter(absolute => isInside(root, absolute)) - const ignored = await ignoredPaths(git, workspace, inRepository.map(workTreePath), signal) + const inWorkspace = [...hunks.keys()].filter(absolute => isInside(root, absolute)) + // A snapshot covers every workspace file except the ignored ones; without one, every file-tool edit counts. + const uncoveredInWorkspace = baseline === null + ? new Set(inWorkspace.map(workTreePath)) + : await ignoredPaths(baseline.git, baseline.workspace, inWorkspace.map(workTreePath), signal) for (const [absolute, list] of hunks) { - // Inside the repository only ignored files are uncovered; outside it, scratch files under a temporary root stay out. - const uncovered = isInside(root, absolute) ? ignored.has(workTreePath(absolute)) : !isTemporaryPath(absolute, baseline.temporaryRoots) - if (uncovered) files.set(absolute, changedFile(baseline, absolute, { ...hunkLineCounts(list), binary: false })) + // Outside the workspace, scratch files under a temporary root stay out. + const uncovered = isInside(root, absolute) + ? uncoveredInWorkspace.has(workTreePath(absolute)) + : !isTemporaryPath(absolute, paths.temporaryRoots) + if (uncovered) files.set(absolute, changedFile(paths, root, absolute, { ...hunkLineCounts(list), binary: false })) } const sorted = [...files.values()].sort(compareDisplay) // An empty list after an earlier in-turn record supersedes that record. @@ -237,11 +254,11 @@ export class TurnRecorder { } function changedFile( - { cwd, workspace, home }: Baseline, absolute: string, counts: { added: number; deleted: number; binary: boolean }, + { cwd, home }: Paths, root: string, absolute: string, counts: { added: number; deleted: number; binary: boolean }, ): WorkspaceChangedFile { return { path: durablePathOf(absolute, cwd), - display: displayPathOf(absolute, cwd, workspace.root, home), + display: displayPathOf(absolute, cwd, root, home), added: counts.added, deleted: counts.deleted, ...counts.binary ? { binary: true as const } : {}, diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index f363ebe9dc..b2c627b3b4 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/packages/deliverables/workspace-changes/tests/plugin.spec.ts @@ -231,9 +231,12 @@ describe('workspace-changes in a repository', () => { }) describe('workspace-changes without a repository', () => { - it('records nothing for a working directory outside any git repository', async () => { + it('summarizes file-tool edits only for a working directory outside any git repository', async () => { const cwd = await scratchDir('dsh-workspace-changes-plain-', cleanups) await writeFile(join(cwd, 'existing.txt'), 'before\n') + await writeFile(join(cwd, 'shell.txt'), 'shell\n') + const outside = await mkdtemp(join(homedir(), '.dsh-workspace-changes-plain-')) + cleanups.push(() => rm(outside, { recursive: true, force: true })) const { ctx } = await boot() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } }) @@ -243,9 +246,23 @@ describe('workspace-changes without a repository', () => { toolCall(session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, { meta: { diffs: [{ path: 'existing.txt', oldText: 'before', newText: 'after\nmore' }] }, }) + toolCall(session, 1, 'edit', { file_path: 'existing.txt', old_string: 'more', new_string: 'more\nagain' }, { + meta: { diffs: [{ path: 'existing.txt', oldText: 'more', newText: 'more\nagain' }] }, + }) + // Shell edits and scratch files under a temporary root stay out; a file elsewhere outside the workspace counts. + await writeFile(join(cwd, 'shell.txt'), 'shell\nedited\n') + toolCall(session, 1, 'bash', { command: 'x' }) + toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, { meta: { diffs: [] } }) + toolCall(session, 1, 'str_replace_editor', { command: 'create', path: join(outside, 'note.txt'), file_text: 'one\ntwo\n' }) endTurn(session, 1) await settle(ctx, session) - expect(changes(ctx, session)).toEqual([]) + expect(changes(ctx, session)).toEqual([{ + turn: 1, cwd, total: 2, + files: [ + { path: 'existing.txt', display: 'existing.txt', added: 4, deleted: 2 }, + { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 2, deleted: 0 }, + ], + }]) expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([]) await expect(stat(join(cwd, '.git'))).rejects.toThrow() }) @@ -301,8 +318,8 @@ describe('workspace-changes without a repository', () => { }) describe('workspace-changes without git', () => { - it('records nothing and reports the absence once', async () => { - const cwd = await scratchDir('dsh-workspace-changes-nogit-', cleanups) + it('summarizes file-tool edits only, even inside a repository, and reports the absence once', async () => { + const cwd = await repository() const { ctx } = await boot() vi.spyOn(ctx.subprocess, 'resolveExecutable').mockRejectedValue(new Error('git: not found')) const info = vi.spyOn(ctx.logger, 'info').mockImplementation(() => undefined) @@ -312,10 +329,11 @@ describe('workspace-changes without git', () => { await settle(ctx, session) await writeFile(join(cwd, `${turn}.txt`), 'x\n') toolCall(session, turn, 'bash', { command: 'x' }) + if (turn === 2) toolCall(session, turn, 'write', { file_path: 'w.txt', content: 'w\n' }, { meta: { diffs: [] } }) endTurn(session, turn) await settle(ctx, session) } - expect(changes(ctx, session)).toEqual([]) + expect(changes(ctx, session)).toEqual([{ turn: 2, cwd, total: 1, files: [{ path: 'w.txt', display: 'w.txt', added: 1, deleted: 0 }] }]) expect(info).toHaveBeenCalledTimes(1) expect(info.mock.calls[0]![0]).toContain('git is unavailable') }) diff --git a/snapshots/web/permission-policy-context/session.v3.jsonl b/snapshots/web/permission-policy-context/session.v3.jsonl index b23cbc6254..4a3bfcd770 100644 --- a/snapshots/web/permission-policy-context/session.v3.jsonl +++ b/snapshots/web/permission-policy-context/session.v3.jsonl @@ -16,7 +16,7 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"session/title","data":{"title":"Can you create or edit","messageSeqs":[12],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me a direct question about whether I can create or edit a normal file under the current policy. They've told me not to call a tool just to discover the policy.\n\nLet me answer based on the current runtime context provided in the system message:\n\nCurrent DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files in the standing mode. However, the guidance says \"do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the Approval policy is \"ask\" - operations that require approval may ask through the configured answerers.\n\nSo I can answer directly: under the current read-only policy, the write and edit tools would be denied, but I can attempt an escalation with justification."},{"type":"text","text":"No — under the current read-only file policy, `write` and `edit` would be denied, though I could attempt a one-shot escalation with justification and wait for your approval."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":59,"outputTokens":201,"cacheReadTokens":14848,"reasoningTokens":163},"stream":[{"type":"chunk","time":1788699482666,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699482666,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," me"," a"," direct"," question"," about"," whether"," I"," can"," create"," or"," edit"," a"," normal"," file"," under"," the"," current"," policy","."," They","'ve"," told"," me"," not"," to"," call"," a"," tool"," just"," to"," discover"," the"," policy",".\n\n","Let"," me"," answer"," based"," on"," the"," current"," runtime"," context"," provided"," in"," the"," system"," message",":\n\n","Current"," D","SH"," file"," policy",":"," read","-only","."," The"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands"," cannot"," modify"," files"," in"," the"," standing"," mode","."," However",","," the"," guidance"," says"," \"","do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," Approval"," policy"," is"," \"","ask","\""," -"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",".\n\n","So"," I"," can"," answer"," directly",":"," under"," the"," current"," read","-only"," policy",","," the"," write"," and"," edit"," tools"," would"," be"," denied",","," but"," I"," can"," attempt"," an"," escalation"," with"," justification","."]},{"type":"chunk","time":1788699482668,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699482668,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No"," —"," under"," the"," current"," read","-only"," file"," policy",","," `","write","`"," and"," `","edit","`"," would"," be"," denied",","," though"," I"," could"," attempt"," a"," one","-shot"," escalation"," with"," justification"," and"," wait"," for"," your"," approval","."]},{"type":"chunk","time":1788699482668,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me a direct question about whether I can create or edit a normal file under the current policy. They've told me not to call a tool just to discover the policy.\n\nLet me answer based on the current runtime context provided in the system message:\n\nCurrent DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files in the standing mode. However, the guidance says \"do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the Approval policy is \"ask\" - operations that require approval may ask through the configured answerers.\n\nSo I can answer directly: under the current read-only policy, the write and edit tools would be denied, but I can attempt an escalation with justification."}}},{"type":"chunk","time":1788699482668,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"No — under the current read-only file policy, `write` and `edit` would be denied, though I could attempt a one-shot escalation with justification and wait for your approval."}}},{"type":"chunk","time":1788699482668,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":201,"cacheReadTokens":14848,"reasoningTokens":163}}},{"type":"chunk","time":1788699482668,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me a direct question about whether I can create or edit a normal file under the current policy. They've told me not to call a tool just to discover the policy.\n\nLet me answer based on the current runtime context provided in the system message:\n\nCurrent DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files in the standing mode. However, the guidance says \"do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the Approval policy is \"ask\" - operations that require approval may ask through the configured answerers.\n\nSo I can answer directly: under the current read-only policy, the write and edit tools would be denied, but I can attempt an escalation with justification."},{"type":"text","text":"No — under the current read-only file policy, `write` and `edit` would be denied, though I could attempt a one-shot escalation with justification and wait for your approval."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":59,"outputTokens":201,"cacheReadTokens":14848,"reasoningTokens":163},"stream":[{"type":"chunk","time":1789457853137,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457853137,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," me"," a"," direct"," question"," about"," whether"," I"," can"," create"," or"," edit"," a"," normal"," file"," under"," the"," current"," policy","."," They","'ve"," told"," me"," not"," to"," call"," a"," tool"," just"," to"," discover"," the"," policy",".\n\n","Let"," me"," answer"," based"," on"," the"," current"," runtime"," context"," provided"," in"," the"," system"," message",":\n\n","Current"," D","SH"," file"," policy",":"," read","-only","."," The"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands"," cannot"," modify"," files"," in"," the"," standing"," mode","."," However",","," the"," guidance"," says"," \"","do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," Approval"," policy"," is"," \"","ask","\""," -"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",".\n\n","So"," I"," can"," answer"," directly",":"," under"," the"," current"," read","-only"," policy",","," the"," write"," and"," edit"," tools"," would"," be"," denied",","," but"," I"," can"," attempt"," an"," escalation"," with"," justification","."]},{"type":"chunk","time":1789457853138,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789457853138,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0],"texts":["No"," —"," under"," the"," current"," read","-only"," file"," policy",","," `","write","`"," and"," `","edit","`"," would"," be"," denied",","," though"," I"," could"," attempt"," a"," one","-shot"," escalation"," with"," justification"," and"," wait"," for"," your"," approval","."]},{"type":"chunk","time":1789457853139,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me a direct question about whether I can create or edit a normal file under the current policy. They've told me not to call a tool just to discover the policy.\n\nLet me answer based on the current runtime context provided in the system message:\n\nCurrent DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files in the standing mode. However, the guidance says \"do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the Approval policy is \"ask\" - operations that require approval may ask through the configured answerers.\n\nSo I can answer directly: under the current read-only policy, the write and edit tools would be denied, but I can attempt an escalation with justification."}}},{"type":"chunk","time":1789457853139,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"No — under the current read-only file policy, `write` and `edit` would be denied, though I could attempt a one-shot escalation with justification and wait for your approval."}}},{"type":"chunk","time":1789457853139,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":201,"cacheReadTokens":14848,"reasoningTokens":163}}},{"type":"chunk","time":1789457853139,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"command/run","data":{"commandId":"{{command:2}}","name":"permission","args":" danger-full-access","source":{"kind":"user"}}} @@ -33,7 +33,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current DSH file sandbox restrictions. According to the current runtime context snapshot, the file policy is \"danger-full-access\" which means no restrictions on file operations. Also, approval prompts are disabled, so I should not request sandbox escalation.\n\nLet me answer directly in one sentence without calling tools."},{"type":"text","text":"No, the DSH file sandbox currently grants full access — it does not restrict file operations."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":202,"outputTokens":87,"cacheReadTokens":14848,"reasoningTokens":66},"stream":[{"type":"chunk","time":1788699482952,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699482952,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," D","SH"," file"," sand","box"," restrictions","."," According"," to"," the"," current"," runtime"," context"," snapshot",","," the"," file"," policy"," is"," \"","danger","-full","-access","\""," which"," means"," no"," restrictions"," on"," file"," operations","."," Also",","," approval"," prompts"," are"," disabled",","," so"," I"," should"," not"," request"," sand","box"," escalation",".\n\n","Let"," me"," answer"," directly"," in"," one"," sentence"," without"," calling"," tools","."]},{"type":"chunk","time":1788699482953,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699482953,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No",","," the"," D","SH"," file"," sand","box"," currently"," grants"," full"," access"," —"," it"," does"," not"," restrict"," file"," operations","."]},{"type":"chunk","time":1788699482953,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current DSH file sandbox restrictions. According to the current runtime context snapshot, the file policy is \"danger-full-access\" which means no restrictions on file operations. Also, approval prompts are disabled, so I should not request sandbox escalation.\n\nLet me answer directly in one sentence without calling tools."}}},{"type":"chunk","time":1788699482953,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"No, the DSH file sandbox currently grants full access — it does not restrict file operations."}}},{"type":"chunk","time":1788699482953,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":87,"cacheReadTokens":14848,"reasoningTokens":66}}},{"type":"chunk","time":1788699482953,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current DSH file sandbox restrictions. According to the current runtime context snapshot, the file policy is \"danger-full-access\" which means no restrictions on file operations. Also, approval prompts are disabled, so I should not request sandbox escalation.\n\nLet me answer directly in one sentence without calling tools."},{"type":"text","text":"No, the DSH file sandbox currently grants full access — it does not restrict file operations."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":202,"outputTokens":87,"cacheReadTokens":14848,"reasoningTokens":66},"stream":[{"type":"chunk","time":1789457853406,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457853406,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," D","SH"," file"," sand","box"," restrictions","."," According"," to"," the"," current"," runtime"," context"," snapshot",","," the"," file"," policy"," is"," \"","danger","-full","-access","\""," which"," means"," no"," restrictions"," on"," file"," operations","."," Also",","," approval"," prompts"," are"," disabled",","," so"," I"," should"," not"," request"," sand","box"," escalation",".\n\n","Let"," me"," answer"," directly"," in"," one"," sentence"," without"," calling"," tools","."]},{"type":"chunk","time":1789457853407,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789457853407,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No",","," the"," D","SH"," file"," sand","box"," currently"," grants"," full"," access"," —"," it"," does"," not"," restrict"," file"," operations","."]},{"type":"chunk","time":1789457853407,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current DSH file sandbox restrictions. According to the current runtime context snapshot, the file policy is \"danger-full-access\" which means no restrictions on file operations. Also, approval prompts are disabled, so I should not request sandbox escalation.\n\nLet me answer directly in one sentence without calling tools."}}},{"type":"chunk","time":1789457853407,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"No, the DSH file sandbox currently grants full access — it does not restrict file operations."}}},{"type":"chunk","time":1789457853407,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":87,"cacheReadTokens":14848,"reasoningTokens":66}}},{"type":"chunk","time":1789457853407,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} {"type":"command/run","data":{"commandId":"{{command:3}}","name":"permission","args":" workspace-write","source":{"kind":"user"}}} @@ -50,7 +50,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:3}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:10}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:11}}"},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WORKSPACE_POLICY_SEEN\"."},{"type":"text","text":"WORKSPACE_POLICY_SEEN"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":245,"outputTokens":27,"cacheReadTokens":14976,"reasoningTokens":18},"stream":[{"type":"chunk","time":1788699483136,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699483136,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WORK","SP","ACE","_P","OL","ICY","_SE","EN","\"."]},{"type":"chunk","time":1788699483136,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699483136,"index":1,"dt":[0,0,1,0,0,0,0],"texts":["WORK","SP","ACE","_P","OL","ICY","_SE","EN"]},{"type":"chunk","time":1788699483137,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WORKSPACE_POLICY_SEEN\"."}}},{"type":"chunk","time":1788699483137,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKSPACE_POLICY_SEEN"}}},{"type":"chunk","time":1788699483137,"chunk":{"type":"usage","usage":{"inputTokens":245,"outputTokens":27,"cacheReadTokens":14976,"reasoningTokens":18}}},{"type":"chunk","time":1788699483137,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WORKSPACE_POLICY_SEEN\"."},{"type":"text","text":"WORKSPACE_POLICY_SEEN"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":245,"outputTokens":27,"cacheReadTokens":14976,"reasoningTokens":18},"stream":[{"type":"chunk","time":1789457853594,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457853594,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WORK","SP","ACE","_P","OL","ICY","_SE","EN","\"."]},{"type":"chunk","time":1789457853594,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789457853594,"index":1,"dt":[0,0,0,0,0,0,0],"texts":["WORK","SP","ACE","_P","OL","ICY","_SE","EN"]},{"type":"chunk","time":1789457853594,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WORKSPACE_POLICY_SEEN\"."}}},{"type":"chunk","time":1789457853594,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKSPACE_POLICY_SEEN"}}},{"type":"chunk","time":1789457853594,"chunk":{"type":"usage","usage":{"inputTokens":245,"outputTokens":27,"cacheReadTokens":14976,"reasoningTokens":18}}},{"type":"chunk","time":1789457853594,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":3,"step":1}} {"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}}} {"type":"command/run","data":{"commandId":"{{command:4}}","name":"permission","args":" read-only","source":{"kind":"user"}}} @@ -63,23 +63,24 @@ {"type":"step/start","data":{"turn":4,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion."}],"source":{"kind":"user","rpcId":"{{rpc:4}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:14}}"},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":4,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current policy is read-only, so write tools will be denied. I should attempt it normally and follow the denial/ escalation guidance. Let me try to write the file."},{"type":"tool-call","id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":265,"outputTokens":103,"cacheReadTokens":15104,"reasoningTokens":35},"stream":[{"type":"chunk","time":1788699483387,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699483387,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," policy"," is"," read","-only",","," so"," write"," tools"," will"," be"," denied","."," I"," should"," attempt"," it"," normally"," and"," follow"," the"," denial","/"," escalation"," guidance","."," Let"," me"," try"," to"," write"," the"," file","."]},{"type":"chunk","time":1788699483387,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699483387,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"","}"]},{"type":"chunk","time":1788699483387,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current policy is read-only, so write tools will be denied. I should attempt it normally and follow the denial/ escalation guidance. Let me try to write the file."}}},{"type":"chunk","time":1788699483387,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}}},{"type":"chunk","time":1788699483387,"chunk":{"type":"usage","usage":{"inputTokens":265,"outputTokens":103,"cacheReadTokens":15104,"reasoningTokens":35}}},{"type":"chunk","time":1788699483387,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":4,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current policy is read-only, so write tools will be denied. I should attempt it normally and follow the denial/ escalation guidance. Let me try to write the file."},{"type":"tool-call","id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":265,"outputTokens":103,"cacheReadTokens":15104,"reasoningTokens":35},"stream":[{"type":"chunk","time":1789457853818,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457853818,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["The"," current"," policy"," is"," read","-only",","," so"," write"," tools"," will"," be"," denied","."," I"," should"," attempt"," it"," normally"," and"," follow"," the"," denial","/"," escalation"," guidance","."," Let"," me"," try"," to"," write"," the"," file","."]},{"type":"chunk","time":1789457853819,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457853819,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"","}"]},{"type":"chunk","time":1789457853819,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current policy is read-only, so write tools will be denied. I should attempt it normally and follow the denial/ escalation guidance. Let me try to write the file."}}},{"type":"chunk","time":1789457853819,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}}},{"type":"chunk","time":1789457853819,"chunk":{"type":"usage","usage":{"inputTokens":265,"outputTokens":103,"cacheReadTokens":15104,"reasoningTokens":35}}},{"type":"chunk","time":1789457853819,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":4,"step":1,"callId":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}} {"type":"tool/result","data":{"turn":4,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1SovMaD1sZjZwhmQuUCb7415"},"content":[{"type":"tool-result","toolCallId":"call_00_1SovMaD1sZjZwhmQuUCb7415","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"{{message:16}}"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","data":{"turn":4,"step":1}} {"type":"step/start","data":{"turn":4,"step":2}} -{"type":"assistant/message","data":{"turn":4,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The write was denied as expected under read-only mode. The escalation guidance says I can retry with sandbox_permissions and justification, but the approval policy says \"ask\" — operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nHowever, the instructions say: \"For the write and edit tools and one-shot bash commands, do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the escalation guidance says: \"retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user\"\n\nSo I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode that suffices) and provide a justification."},{"type":"tool-call","id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:17}}"},"usage":{"inputTokens":176,"outputTokens":306,"cacheReadTokens":15360,"reasoningTokens":175},"stream":[{"type":"chunk","time":1788699483409,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699483409,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," write"," was"," denied"," as"," expected"," under"," read","-only"," mode","."," The"," escalation"," guidance"," says"," I"," can"," ret","ry"," with"," sand","box","_per","missions"," and"," justification",","," but"," the"," approval"," policy"," says"," \"","ask","\""," —"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",";"," without"," an"," available"," answer","er",","," the"," request"," fails"," closed",".\n\n","However",","," the"," instructions"," say",":"," \"","For"," the"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands",","," do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," escalation"," guidance"," says",":"," \"","ret","ry"," this"," exact"," operation"," once"," with"," sand","box","_per","missions"," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," +"," justification",";"," the"," approval"," prompt"," asks"," the"," user","\"\n\n","So"," I"," need"," to"," ret","ry"," with"," sand","box","_per","missions"," set"," to"," \"","works","pace","-w","rite","\""," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," and"," provide"," a"," justification","."]},{"type":"chunk","time":1788699483410,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699483410,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","works","pace","-w","rite","\"",", ","\"","just","ification","\"",": ","\"","The"," user"," explicitly"," asked"," me"," to"," create"," this"," file",";"," workspace","-w","rite"," is"," the"," narrow","est"," mode"," sufficient"," to"," write"," to"," the"," session"," workspace",".","\"","}"]},{"type":"chunk","time":1788699483411,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The write was denied as expected under read-only mode. The escalation guidance says I can retry with sandbox_permissions and justification, but the approval policy says \"ask\" — operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nHowever, the instructions say: \"For the write and edit tools and one-shot bash commands, do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the escalation guidance says: \"retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user\"\n\nSo I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode that suffices) and provide a justification."}}},{"type":"chunk","time":1788699483411,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace.\"}"}}},{"type":"chunk","time":1788699483411,"chunk":{"type":"usage","usage":{"inputTokens":176,"outputTokens":306,"cacheReadTokens":15360,"reasoningTokens":175}}},{"type":"chunk","time":1788699483411,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":4,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The write was denied as expected under read-only mode. The escalation guidance says I can retry with sandbox_permissions and justification, but the approval policy says \"ask\" — operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nHowever, the instructions say: \"For the write and edit tools and one-shot bash commands, do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the escalation guidance says: \"retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user\"\n\nSo I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode that suffices) and provide a justification."},{"type":"tool-call","id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:17}}"},"usage":{"inputTokens":176,"outputTokens":306,"cacheReadTokens":15360,"reasoningTokens":175},"stream":[{"type":"chunk","time":1789457853839,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457853839,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," write"," was"," denied"," as"," expected"," under"," read","-only"," mode","."," The"," escalation"," guidance"," says"," I"," can"," ret","ry"," with"," sand","box","_per","missions"," and"," justification",","," but"," the"," approval"," policy"," says"," \"","ask","\""," —"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",";"," without"," an"," available"," answer","er",","," the"," request"," fails"," closed",".\n\n","However",","," the"," instructions"," say",":"," \"","For"," the"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands",","," do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," escalation"," guidance"," says",":"," \"","ret","ry"," this"," exact"," operation"," once"," with"," sand","box","_per","missions"," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," +"," justification",";"," the"," approval"," prompt"," asks"," the"," user","\"\n\n","So"," I"," need"," to"," ret","ry"," with"," sand","box","_per","missions"," set"," to"," \"","works","pace","-w","rite","\""," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," and"," provide"," a"," justification","."]},{"type":"chunk","time":1789457853840,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457853840,"index":1,"dt":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","works","pace","-w","rite","\"",", ","\"","just","ification","\"",": ","\"","The"," user"," explicitly"," asked"," me"," to"," create"," this"," file",";"," workspace","-w","rite"," is"," the"," narrow","est"," mode"," sufficient"," to"," write"," to"," the"," session"," workspace",".","\"","}"]},{"type":"chunk","time":1789457853841,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The write was denied as expected under read-only mode. The escalation guidance says I can retry with sandbox_permissions and justification, but the approval policy says \"ask\" — operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nHowever, the instructions say: \"For the write and edit tools and one-shot bash commands, do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the escalation guidance says: \"retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user\"\n\nSo I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode that suffices) and provide a justification."}}},{"type":"chunk","time":1789457853841,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace.\"}"}}},{"type":"chunk","time":1789457853841,"chunk":{"type":"usage","usage":{"inputTokens":176,"outputTokens":306,"cacheReadTokens":15360,"reasoningTokens":175}}},{"type":"chunk","time":1789457853841,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":4,"step":2,"callId":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace.\"}"}} {"type":"approval/asked","data":{"id":"{{approval:1}}","toolName":"write","callId":"call_00_NRXSzM55hPMs3seJDD8k7920","reason":"escalate sandbox to workspace-write: The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace."}} {"type":"approval/decided","data":{"id":"{{approval:1}}","outcome":"allowed-once"}} {"type":"tool/result","data":{"turn":4,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_NRXSzM55hPMs3seJDD8k7920"},"content":[{"type":"tool-result","toolCallId":"call_00_NRXSzM55hPMs3seJDD8k7920","content":[{"type":"text","text":"{{cwd}}/policy-neutral.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:18}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[70],"surfaceOp":"append"} {"type":"step/end","data":{"turn":4,"step":2}} {"type":"step/start","data":{"turn":4,"step":3}} -{"type":"assistant/message","data":{"turn":4,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created. Now I need to verify its contents as instructed."},{"type":"tool-call","id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","arguments":"{\"file_path\": \"policy-neutral.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":180,"outputTokens":61,"cacheReadTokens":15744,"reasoningTokens":15},"stream":[{"type":"chunk","time":1788699483437,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699483437,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created","."," Now"," I"," need"," to"," verify"," its"," contents"," as"," instructed","."]},{"type":"chunk","time":1788699483437,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699483437,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"","}"]},{"type":"chunk","time":1788699483437,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created. Now I need to verify its contents as instructed."}}},{"type":"chunk","time":1788699483437,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","arguments":"{\"file_path\": \"policy-neutral.txt\"}"}}},{"type":"chunk","time":1788699483437,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":61,"cacheReadTokens":15744,"reasoningTokens":15}}},{"type":"chunk","time":1788699483437,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":4,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created. Now I need to verify its contents as instructed."},{"type":"tool-call","id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","arguments":"{\"file_path\": \"policy-neutral.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":180,"outputTokens":61,"cacheReadTokens":15744,"reasoningTokens":15},"stream":[{"type":"chunk","time":1789457853866,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457853866,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created","."," Now"," I"," need"," to"," verify"," its"," contents"," as"," instructed","."]},{"type":"chunk","time":1789457853866,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457853866,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"","}"]},{"type":"chunk","time":1789457853866,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created. Now I need to verify its contents as instructed."}}},{"type":"chunk","time":1789457853866,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","arguments":"{\"file_path\": \"policy-neutral.txt\"}"}}},{"type":"chunk","time":1789457853866,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":61,"cacheReadTokens":15744,"reasoningTokens":15}}},{"type":"chunk","time":1789457853866,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":4,"step":3,"callId":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","arguments":"{\"file_path\": \"policy-neutral.txt\"}"}} {"type":"tool/result","data":{"turn":4,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_2Iq0ZY5UVHgctdG7nana2758"},"content":[{"type":"tool-result","toolCallId":"call_00_2Iq0ZY5UVHgctdG7nana2758","content":[{"type":"text","text":"{{cwd}}/policy-neutral.txt\nfile\n\n1: POLICY_NEUTRAL_OK\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:20}}"},"meta":{"path":"{{cwd}}/policy-neutral.txt","offset":1,"lines":[{"number":1,"text":"POLICY_NEUTRAL_OK"}],"totalLines":1}},"sourceEventSeqs":[77],"surfaceOp":"append"} {"type":"step/end","data":{"turn":4,"step":3}} {"type":"step/start","data":{"turn":4,"step":4}} -{"type":"assistant/message","data":{"turn":4,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created and its contents are verified: \"POLICY_NEUTRAL_OK\". Task complete."},{"type":"text","text":"Done — **policy-neutral.txt** was created (via workspace-write escalation) and verified to contain exactly `POLICY_NEUTRAL_OK`."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:21}}"},"usage":{"inputTokens":211,"outputTokens":53,"cacheReadTokens":15872,"reasoningTokens":22},"stream":[{"type":"chunk","time":1788699483454,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699483454,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created"," and"," its"," contents"," are"," verified",":"," \"","POL","ICY","_","NE","UT","RAL","_OK","\"."," Task"," complete","."]},{"type":"chunk","time":1788699483454,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699483454,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["Done"," —"," **","policy","-neutral",".txt","**"," was"," created"," (","via"," workspace","-w","rite"," escalation",")"," and"," verified"," to"," contain"," exactly"," `","POL","ICY","_","NE","UT","RAL","_OK","`."]},{"type":"chunk","time":1788699483454,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created and its contents are verified: \"POLICY_NEUTRAL_OK\". Task complete."}}},{"type":"chunk","time":1788699483454,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Done — **policy-neutral.txt** was created (via workspace-write escalation) and verified to contain exactly `POLICY_NEUTRAL_OK`."}}},{"type":"chunk","time":1788699483454,"chunk":{"type":"usage","usage":{"inputTokens":211,"outputTokens":53,"cacheReadTokens":15872,"reasoningTokens":22}}},{"type":"chunk","time":1788699483454,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":4,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created and its contents are verified: \"POLICY_NEUTRAL_OK\". Task complete."},{"type":"text","text":"Done — **policy-neutral.txt** was created (via workspace-write escalation) and verified to contain exactly `POLICY_NEUTRAL_OK`."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:21}}"},"usage":{"inputTokens":211,"outputTokens":53,"cacheReadTokens":15872,"reasoningTokens":22},"stream":[{"type":"chunk","time":1789457853881,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457853881,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created"," and"," its"," contents"," are"," verified",":"," \"","POL","ICY","_","NE","UT","RAL","_OK","\"."," Task"," complete","."]},{"type":"chunk","time":1789457853881,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789457853881,"index":1,"dt":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["Done"," —"," **","policy","-neutral",".txt","**"," was"," created"," (","via"," workspace","-w","rite"," escalation",")"," and"," verified"," to"," contain"," exactly"," `","POL","ICY","_","NE","UT","RAL","_OK","`."]},{"type":"chunk","time":1789457853882,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created and its contents are verified: \"POLICY_NEUTRAL_OK\". Task complete."}}},{"type":"chunk","time":1789457853882,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Done — **policy-neutral.txt** was created (via workspace-write escalation) and verified to contain exactly `POLICY_NEUTRAL_OK`."}}},{"type":"chunk","time":1789457853882,"chunk":{"type":"usage","usage":{"inputTokens":211,"outputTokens":53,"cacheReadTokens":15872,"reasoningTokens":22}}},{"type":"chunk","time":1789457853882,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":4,"step":4}} +{"type":"workspace/changes","data":{"turn":4}} {"type":"turn/end","data":{"turn":4,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/present-svg/session.v3.jsonl b/snapshots/web/present-svg/session.v3.jsonl index f37b64f7c9..262511ea57 100644 --- a/snapshots/web/present-svg/session.v3.jsonl +++ b/snapshots/web/present-svg/session.v3.jsonl @@ -12,17 +12,18 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"session/title","data":{"title":"简单画一个 SVG 表示冯诺依曼","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."},{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"},{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55},"stream":[{"type":"chunk","time":1788926975747,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788926975748,"index":0,"dt":[75,23,0,0,0,0,21,0,0,0,0,0,21,0,0,0,0,0,22,0,0,0,0,0,22,0,0,22,22,23,22,0,0,0,0,22,23,0,21,23,22,0,23,0,21,23,0,0,1,0,0,21,0,22],"texts":["The"," user"," wants"," a"," simple"," SVG"," diagram"," of"," the"," von"," Neumann"," architecture",","," saved"," as"," von","-","neum","ann",".svg","."," Let"," me"," create"," a"," clean"," SVG"," showing"," CPU"," (","control"," unit",","," AL","U","),"," memory",","," input",","," and"," output"," with"," bidirectional"," data","/","control"," buses",".\n\n","Let"," me"," write"," the"," file","."]},{"type":"chunk","time":1788926976243,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788926976244,"index":1,"dt":[21,22,22,23,0,0,0,22,21,1,0],"texts":["我来","创建一个","冯","诺","依","曼","架构","的"," SVG"," ","示意图","。"]},{"type":"chunk","time":1788926976420,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788926976420,"index":2,"dt":[22,0,0,0,22,0,0,0,22,0,0,0,0,1,22,0,0,0,0,22,0,0,22,0,1,0,0,21,0,0,23,0,1,0,0,22,0,0,0,0,0,20,0,0,22,1,0,0,0,0,22,0,0,0,0,21,23,1,0,0,0,21,0,0,0,0,0,22,1,0,0,0,0,21,0,1,0,0,0,21,0,1,0,0,0,21,22,1,0,0,0,0,21,1,0,0,22,1,0,0,0,21,0,1,0,0,0,21,0,0,22,1,0,0,21,21,1,0,22,0,0,0,23,0,0,0,0,0,0,39,0,0,0,0,1,3,0,0,0,0,0,22,0,0,0,0,0,22,0,0,0,0,0,22,1,0,0,0,0,21,0,0,0,0,0,21,0,0,0,22,0,1,0,0,0,22,0,0,0,0,0,24,0,0,0,1,0,19,0,0,0,0,0,22,22,0,0,0,0,21,1,0,0,0,0,22,0,0,0,0,0,22,0,0,0,0,0,22,1,21,22,0,0,0,22,1,0,0,0,0,22,1,0,20,1,0,0,0,0,25,0,0,0,0,0,21,0,0,1,0,0,21,0,0,22,0,0,22,1,0,21,0,0,0,0,1,22,0,0,0,20,0,0,0,22,1,0,0,0,0,0,23,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,22,0,0,0,21,1,21,0,0,0,1,0,21,0,0,0,0,23,0,0,0,0,0,0,22,0,29,0,0,0,0,0,15,0,0,0,0,22,0,0,0,0,0,22,0,0,0,0,0,22,0,0,0,0,22,0,0,1,0,0,22,0,0,0,0,0,22,1,22,1,0,0,0,0,21,1,0,0,0,0,21,0,0,0,22,0,0,0,0,22,0,0,0,1,0,21,23,0,0,0,0,1,21,1,0,0,0,0,21,0,0,0,0,1,22,0,0,0,0,0,22,0,0,0,0,1,21,0,0,0,1,0,59,1,1,0,0,0,1,0,0,0,5,0,0,0,0,0,0,21,0,0,0,0,0,23,0,0,0,0,0,21,1,0,0,0,0,22,0,0,0,0,22,22,1,21,1,0,0,0,0,0,21,0,1,0,0,0,21,1,0,0,0,0,22,1,0,0,0,0,20,1,0,0,0,21,27,1,16,0,23,0,0,0,0,1,21,0,0,1,0,0,21,0,0,0,0,1,21,0,0,0,0,0,23,1,0,0,0,0,21,0,0,0,0,0,20,23,0,0,0,0,23,0,0,0,22,0,0,0,0,1,21,0,0,0,0,0,22,0,22,1,0,0,21,0,0,0,0,1,22,0,0,0,0,22,0,0,0,0,0,22,0,0,0,0,22,0,0,0,0,1,21,0,0,0,0,22,0,0,0,0,1,0,21,0,1,0,0,0,21,1,0,0,0,0,22,0,0,0,0,21,1,22,0,0,0,0,0,23,0,0,0,0,0,22,1,0,0,0,0,21,0,0,0,0,1,22,0,0,0,0,0,22,0,0,21,1,0,0,21,1,0,0,0,0,21,0,0,0,0,0,25,0,0,0,0,0,20,0,0,0,0,22,0,0,0,0,0,28,0,0,0,0,0,16,1,0,0,21,22,1,23,1,1,0,0,0,20,0,0,0,0,0,21,1,0,0,0,0,22,0,0,0,0,0,22,1,0,0,0,0,22,0,0,0,0,1,21,0,1,21,30,0,1,14,0,22,1,0,0,0,1,22,0,0,0,0,0,22,0,0,0,0,1,21,0,0,0,0,0,22,0,0,0,0,1,21,0,22,0,22,0,0,0,0,0,22,0,0,0,0,22,0,0,0,0,1,21,0,0,1,22,0,0,0,22,1,0,0,0,0,23,1,0,0,0,0,28,1,0,0,13,0,0,0,0,0,22,0,0,0,0,23,0,0,0,1,0,0,20,1,21,0,0,0,0,0,23,0,0,0,0,0,21,1,0,0,0,0,22,0,0,1,0,0,21,22,1,0,0,22,0,0,0,1,0,21,0,0,0,0,0,22,0,0,0,0,0,22,0,0,0,0,0,23,0,0,0,22,0,0,22,22,1,0,0,22,0,0,0,0,1,0,21,0,1,0,0,1,21,1,0,0,0,0,21,0,0,0,1,21,0,0,0,22,0,1,22,0,22,22,0,0,0,0,1,21,0,0,0,0,0,23,0,1,0,0,1,19,1,0,23,1,0,0,0,0,22,0,0,0,0,0,21,1,0,0,0,0,21,1,20,1,0,0,0,0,28,0,0,0,0,0,16,0,0,0,0,1,21,1,0,0,22,0,0,0,0,0,22,0,1,0,0,22,0,0,0,0,0,21,1,0,0,0,0,22,0,0,0,0,0,22,0,0,0,0,0,22,1,0,0,0,0,21,1,0,0,0,0,23,0,0,0,0,0,21,0,0,0,23,0,0,0,0,0,21,1,0,0,0,0,21,0,0,0,0,1,21,0,1,0,0,0,21,0,0,0,0,0,22,1,0,0,0,0,29,0,1,0,0,0,14,0,0,0,0,0,21,0,0,0,0,23,0,0,0,1,0,21,0,0,0,0,0,22,0,0,0,0,0,22,0,0,0,0,0,26,18,1,0,0,0,0,21,0,0,0,0,1,21,0,0,22,0,0,0,23,0,0,0,0,0,21,1,0,0,0,0,21,0,0,1,0,0,21,1,0,0,0,0,22,0,0,0,0,31,0,0,0,0,0,0,14,0,1,0,0,0,21,0,0,0,0,0,21,1,0,0,0,0,22,0,0,0,0,22,0,0,0,0,0,0,34,0,0,0,1,0,9,0,0,0,0,0,22,0,1,0,0,0,21,0,0,0,23,0,1,0,0,0,0,21,22,1,0,21,1,0,0,22,0,22,1,0,21,0,24,21,0,0,0,21,1,0,0,22,22,0,0,0,0,22,0,0,0,0,22,0,0,0,0,1,21,0,0,0,22,0,0,0,24,0,0,1,0,0,19,1,0,0,0,0,22,0,0,0,0,0,23,0,0,0,0,0,20,0,0,28,0,0,0,0,1,15,1,0,0,0,21,1,0,0,0,0,21,22,1,0,21,22,1,0,24,1,0,0,0,18,1,0,0,0,0,21,1,0,0,0,0,22,0,0,0,0,0,21,1,0,22,1,0,0,0,20,22,22,0,1,0,0,0,23,1,0,0,0,55,1,0,0,0,1,0,0,0,5,4,0,0,18,1,0,0,0,0,23,1,0,0,0,0,19,0,0,0,1,22,0,0,22,0,22,0,0,0,22,1,0,0,22,0,0,0,0,0,23,0,0,1,0,19,1,22,0,22,1,22,22,23,21,0,0,0,0,1,21,0,0,22,0,23,0,22,0,1,0,22,0,1,0,0,0,20,0,0,0,1,0,22,1,0,0,21,0,0,0,0,0,22,1,0,0,0,0,21,0,0,22,1,22,1,0,0,0,0,21,1,0,0,0,0,21,1,0,0,0,0,22,0,0,0,0,1,27,0,0,0,0,1,15,0,22,0,1,0,21,1,0,0,0,0,21,0,0,0,0,22,0,0,0,0,1,21,0,0,0,0,0,22,22,0,0,0,1,22,1,0,1,0,0,0,19,1,0,0,0,0,21,0,1,0,0,0,21,1,0,0,0,0,22,0,0,0,22,0,0,0,0,1,21,0,0,0,0,0,23,0,0,0,0,0,22,0,0,0,0,0,23,0,0,0,23,1,0,0,0,0,21,1,0,0,0,0,22,0,3,0,0,18,1,0,0,0,0,0,22,0,0,1,0,0,20,1,0,0,0,0,23,0,0,0,0,0,21,0,0,0,0,0,23,0,0,0,0,0,21,0,0,22,0,0,0,0,1,21,0,0,0,0,0,22,1,0,0,0,0,22,0,0,0,0,1,21,0,0,0,0,1,20,0,0,0,1,0,21,0,1,0,0,0,21,1,0,0,0,0,21,0,1,0,0,0,22,0,0,0,0,0,22,0,0,22,0,0,0,0,22,1,21,1,22,0,22,0,0,21,23,1,27,0,0,0,16,0,1,0,22,0,22,0,0,0,22,23,0,0,0,0,22,0,1,0,21,1,0,0,0,0,22,0,0,0,23,0,21,1,0,0,0,0,22,0,1,0,0,0,22,0,0,0,0,29,1,0,0,0,0,14,0,0,0,0,0,22,0,0,22,0,0,0,22,1,0,0,0,0,21,1,0,0,0,0,22,0,0,24,20,1,0,0,0,21,0,0,0,0,0,21,45,0,1,0,0,0,0,21,0,0,0,0,0,22,1,0,0,0,0,21,0,0,0,0,1,22,0,0,0,0,1,21,1,0,0,0,2,19,0,0,0,0,0,23,0,0,0,0,1,21,0,0,0,0,1,22,0,0,0,0,1,20,0,0,0,0,0,22,0,0,23,0,0,0,0,0,22,0,0,0,0,0,23,0,0,0,0,0,21,1,0,0,0,0,22,0,0,0,1,0,21,1,0,0,0,0,21,0,23,0,22,0,0,0,1,0,21,0,0,0,0,1,22,0,0,0,0,0,21,1,0,0,0,0,53,1,0,0,0,0,0,0,26,0,0,0,21,0,0,11,0,23,20,22,0,22,22,1,0,21,0,0,0,0,1,21,1],"id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","args":["","{","\"","file","_path","\"",": ","\"","/","private","/","var","/f","old","ers","/n","5","/f","339","h","wh","15","_g","f","4","gk","8","ns","rt","9","qm","400","00","gn","/T","/d","sh","-web","-e","2","e","-w","s","-g","Ou","Z","JC","/","works","pace","/v","on","-","neum","ann",".svg","\"",", ","\"","content","\"",": ","\"","<","svg"," xmlns","=\\\"","http","://","www",".w","3",".org","/","200","0","/s","vg","\\\""," view","Box","=\\\"","0"," ","0"," ","900"," ","560","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," font","-family","=\\\"","Hel","vetica",","," A","rial",","," sans","-serif","\\\">\\n"," "," <","title",">","冯","诺","依","曼","架构","\\n\\n"," "," <","def","s",">\\n"," "," <","mark","er"," id","=\\\"","arrow","\\\""," marker","Width","=\\\"","10","\\\""," marker","Height","=\\\"","10","\\\""," ref","X","=\\\"","8","\\\""," ref","Y","=\\\"","3","\\\""," orient","=\\\"","auto","\\\">\\n"," "," <","path"," d","=\\\"","M","0",",","0"," L","8",",","3"," L","0",",","6"," Z","\\\""," fill","=\\\"#","555","\\\"/>\\n"," "," \\n"," "," \\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","0","\\\""," y","=\\\"","0","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," fill","=\\\"#","f","af","bf","c","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","34","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","22","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","222","\\\">","冯","诺","依","曼","体系","结构"," (","Von"," Neumann"," Architecture",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","70","\\\""," width","=\\\"","300","\\\""," height","=\\\"","240","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","f","6","f","eb","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","98","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","f","6","f","eb","\\\">","中央","处理器"," CPU","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","325","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","d","df","4","ff","\\\""," stroke","=\\\"#","54","ae","ff","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","096","9","da","\\\">","控制","单元","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","0","b","539","4","\\\">","CU","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","指令","译","码"," ·"," ","时序","控制","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","455","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","fff","8","c","5","\\\""," stroke","=\\\"#","d","4","a","72","c","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","9","a","670","0","\\\">","算术","逻辑","单元","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","7","a","520","0","\\\">","ALU","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","算术","运算"," ·"," ","逻辑","运算","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","330","\\\""," y","=\\\"","186","\\\""," width","=\\\"","110","\\\""," height","=\\\"","56","\\\""," rx","=\\\"","6","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","2","\\\""," stroke","-d","ash","array","=\\\"","4",",","3","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","208","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","寄存器","组","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","224","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","Reg","isters"," /"," ","缓存","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","400","\\\""," width","=\\\"","300","\\\""," height","=\\\"","110","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","428","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","cf","222","e","\\\">","内","存储器"," Memory","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","452","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," fill","=\\\"#","444","\\\">","存储","单元",":"," ","指令","和数据","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","470","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","666","\\\">","(","存放","程序","与","数据",",","同一","总线","存取",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","40","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","输入","设备","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Input","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","键盘"," ·"," ","鼠标"," ·"," ","磁盘","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","690","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","bf","398","9","\\\">","输出","设备","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Output","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","显示器"," ·"," ","打印机","\\n\\n"," "," \\n"," "," \\n"," "," <","path"," d","=\\\"","M","450",",","320"," L","450",",","398","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","3","\\\"/>\\n"," "," <","text"," x","=\\\"","420","\\\""," y","=\\\"","362","\\\""," text","-an","chor","=\\\"","end","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","cf","222","e","\\\""," transform","=\\\"","rotate","(-","90"," ","420"," ","362",")","\\\">","地址","总线","\\n\\n"," "," \\n"," "," <","line"," x","1","=\\\"","300","\\\""," y","1","=\\\"","355","\\\""," x","2","=\\\"","600","\\\""," y","2","=\\\"","355","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","1","\\\""," stroke","-d","ash","array","=\\\"","2",",","3","\\\""," opacity","=\\\"","0",".","0","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","353","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","555","\\\">","←"," ","数据","总线"," /"," ","控制","总线"," →","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","212",",","210"," L","298",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","212",",","196"," L","298",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","数据","(","输入",")\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","602",",","210"," L","688",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","602",",","196"," L","688",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","bf","398","9","\\\">","数据","(","输出",")\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","125",",","272"," L","125",",","300"," Q","125",",","340"," ","175",",","340"," L","298",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","160","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(-","8"," ","160"," ","328",")","\\\">","总线","\\n\\n"," "," <","path"," d","=\\\"","M","775",",","272"," L","775",",","300"," Q","775",",","340"," ","725",",","340"," L","602",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","740","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(","8"," ","740"," ","328",")","\\\">","总线","\\n\\n"," "," \\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","530","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11",".","5","\\\""," fill","=\\\"#","666","\\\">","核心","思想",":"," ","指令","与","数据","以","二进制","形式","统一","存放在","存储器","中"," ——"," “","存储","程序","”","\\n","\\n","\"","}"]},{"type":"chunk","time":1788926986045,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."}}},{"type":"chunk","time":1788926986045,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"}}},{"type":"chunk","time":1788926986045,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}}},{"type":"chunk","time":1788926986045,"chunk":{"type":"usage","usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55}}},{"type":"chunk","time":1788926986045,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."},{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"},{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55},"stream":[{"type":"chunk","time":1789457782571,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457782571,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," a"," simple"," SVG"," diagram"," of"," the"," von"," Neumann"," architecture",","," saved"," as"," von","-","neum","ann",".svg","."," Let"," me"," create"," a"," clean"," SVG"," showing"," CPU"," (","control"," unit",","," AL","U","),"," memory",","," input",","," and"," output"," with"," bidirectional"," data","/","control"," buses",".\n\n","Let"," me"," write"," the"," file","."]},{"type":"chunk","time":1789457782572,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789457782572,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0],"texts":["我来","创建一个","冯","诺","依","曼","架构","的"," SVG"," ","示意图","。"]},{"type":"chunk","time":1789457782572,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457782572,"index":2,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","args":["","{","\"","file","_path","\"",": ","\"","/","private","/","var","/f","old","ers","/n","5","/f","339","h","wh","15","_g","f","4","gk","8","ns","rt","9","qm","400","00","gn","/T","/d","sh","-web","-e","2","e","-w","s","-g","Ou","Z","JC","/","works","pace","/v","on","-","neum","ann",".svg","\"",", ","\"","content","\"",": ","\"","<","svg"," xmlns","=\\\"","http","://","www",".w","3",".org","/","200","0","/s","vg","\\\""," view","Box","=\\\"","0"," ","0"," ","900"," ","560","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," font","-family","=\\\"","Hel","vetica",","," A","rial",","," sans","-serif","\\\">\\n"," "," <","title",">","冯","诺","依","曼","架构","\\n\\n"," "," <","def","s",">\\n"," "," <","mark","er"," id","=\\\"","arrow","\\\""," marker","Width","=\\\"","10","\\\""," marker","Height","=\\\"","10","\\\""," ref","X","=\\\"","8","\\\""," ref","Y","=\\\"","3","\\\""," orient","=\\\"","auto","\\\">\\n"," "," <","path"," d","=\\\"","M","0",",","0"," L","8",",","3"," L","0",",","6"," Z","\\\""," fill","=\\\"#","555","\\\"/>\\n"," "," \\n"," "," \\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","0","\\\""," y","=\\\"","0","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," fill","=\\\"#","f","af","bf","c","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","34","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","22","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","222","\\\">","冯","诺","依","曼","体系","结构"," (","Von"," Neumann"," Architecture",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","70","\\\""," width","=\\\"","300","\\\""," height","=\\\"","240","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","f","6","f","eb","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","98","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","f","6","f","eb","\\\">","中央","处理器"," CPU","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","325","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","d","df","4","ff","\\\""," stroke","=\\\"#","54","ae","ff","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","096","9","da","\\\">","控制","单元","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","0","b","539","4","\\\">","CU","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","指令","译","码"," ·"," ","时序","控制","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","455","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","fff","8","c","5","\\\""," stroke","=\\\"#","d","4","a","72","c","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","9","a","670","0","\\\">","算术","逻辑","单元","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","7","a","520","0","\\\">","ALU","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","算术","运算"," ·"," ","逻辑","运算","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","330","\\\""," y","=\\\"","186","\\\""," width","=\\\"","110","\\\""," height","=\\\"","56","\\\""," rx","=\\\"","6","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","2","\\\""," stroke","-d","ash","array","=\\\"","4",",","3","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","208","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","寄存器","组","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","224","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","Reg","isters"," /"," ","缓存","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","400","\\\""," width","=\\\"","300","\\\""," height","=\\\"","110","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","428","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","cf","222","e","\\\">","内","存储器"," Memory","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","452","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," fill","=\\\"#","444","\\\">","存储","单元",":"," ","指令","和数据","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","470","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","666","\\\">","(","存放","程序","与","数据",",","同一","总线","存取",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","40","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","输入","设备","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Input","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","键盘"," ·"," ","鼠标"," ·"," ","磁盘","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","690","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","bf","398","9","\\\">","输出","设备","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Output","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","显示器"," ·"," ","打印机","\\n\\n"," "," \\n"," "," \\n"," "," <","path"," d","=\\\"","M","450",",","320"," L","450",",","398","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","3","\\\"/>\\n"," "," <","text"," x","=\\\"","420","\\\""," y","=\\\"","362","\\\""," text","-an","chor","=\\\"","end","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","cf","222","e","\\\""," transform","=\\\"","rotate","(-","90"," ","420"," ","362",")","\\\">","地址","总线","\\n\\n"," "," \\n"," "," <","line"," x","1","=\\\"","300","\\\""," y","1","=\\\"","355","\\\""," x","2","=\\\"","600","\\\""," y","2","=\\\"","355","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","1","\\\""," stroke","-d","ash","array","=\\\"","2",",","3","\\\""," opacity","=\\\"","0",".","0","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","353","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","555","\\\">","←"," ","数据","总线"," /"," ","控制","总线"," →","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","212",",","210"," L","298",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","212",",","196"," L","298",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","数据","(","输入",")\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","602",",","210"," L","688",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","602",",","196"," L","688",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","bf","398","9","\\\">","数据","(","输出",")\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","125",",","272"," L","125",",","300"," Q","125",",","340"," ","175",",","340"," L","298",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","160","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(-","8"," ","160"," ","328",")","\\\">","总线","\\n\\n"," "," <","path"," d","=\\\"","M","775",",","272"," L","775",",","300"," Q","775",",","340"," ","725",",","340"," L","602",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","740","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(","8"," ","740"," ","328",")","\\\">","总线","\\n\\n"," "," \\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","530","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11",".","5","\\\""," fill","=\\\"#","666","\\\">","核心","思想",":"," ","指令","与","数据","以","二进制","形式","统一","存放在","存储器","中"," ——"," “","存储","程序","”","\\n","\\n","\"","}"]},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"usage","usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_yxav8LdZ9iwWpgkUnspF6361"},"content":[{"type":"tool-result","toolCallId":"call_00_yxav8LdZ9iwWpgkUnspF6361","content":[{"type":"text","text":"{{cwd}}/von-neumann.svg\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."},{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14},"stream":[{"type":"chunk","time":1788926690104,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788926690104,"index":0,"dt":[100,23,1,0,0,0,0,20,0,22,1,0,0],"texts":["The"," file"," is"," created","."," Let"," me"," present"," it"," as"," a"," deliver","able","."]},{"type":"chunk","time":1788926690334,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788926690335,"index":1,"dt":[21,0,0,0,0,39,0,0,0,0,3,1,0,0,0,0,43,0,0,0,0,2,0,0,0,22,0,0,29,19],"id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","args":["","{","\"","files","\"",": ","[","{\"","path","\":"," \"","von","-","neum","ann",".svg","\","," \"","description","\":"," \"","冯","诺","依","曼","架构","示意图"," SVG","\"","}]","}"]},{"type":"chunk","time":1788926690694,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."}}},{"type":"chunk","time":1788926690695,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}}},{"type":"chunk","time":1788926690695,"chunk":{"type":"usage","usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14}}},{"type":"chunk","time":1788926690695,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."},{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14},"stream":[{"type":"chunk","time":1789457782632,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457782632,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," is"," created","."," Let"," me"," present"," it"," as"," a"," deliver","able","."]},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457782632,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","args":["","{","\"","files","\"",": ","[","{\"","path","\":"," \"","von","-","neum","ann",".svg","\","," \"","description","\":"," \"","冯","诺","依","曼","架构","示意图"," SVG","\"","}]","}"]},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"usage","usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}} {"type":"deliverables/presented","data":{"turn":1,"callId":"call_00_otuUentgajQb2RkFVoKT3699","files":[{"path":"von-neumann.svg","description":"冯诺依曼架构示意图 SVG"}]}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_otuUentgajQb2RkFVoKT3699"},"content":[{"type":"tool-result","toolCallId":"call_00_otuUentgajQb2RkFVoKT3699","content":[{"type":"text","text":"Presented von-neumann.svg"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"已创建 `von-neumann.svg`,一个简洁的冯诺依曼架构示意图,包含:\n\n- **CPU(中央处理器)**:内含控制单元 CU、算术逻辑单元 ALU 和寄存器组\n- **内存储器 Memory**:标注\"指令和数据统一存放\"(存储程序思想)\n- **输入设备 / 输出设备**:分别用绿色和粉色表示数据流向\n- **总线连接**:CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向\n\n下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":56,"outputTokens":145,"totalTokens":10825,"cacheReadTokens":10624,"reasoningTokens":0},"stream":[{"type":"chunk","time":1788926691167,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788926691167,"index":0,"dt":[23,0,1,0,0,1,19,1,0,0,21,0,0,0,22,1,0,22,22,1,0,0,0,21,1,21,1,0,0,0,23,20,1,0,22,0,1,21,0,0,0,0,22,1,0,0,0,0,21,0,0,0,1,21,1,0,23,1,0,27,0,0,0,0,0,14,1,0,0,0,21,0,0,0,0,0,22,22,1,22,29,21,0,0,0,16,0,0,0,0,22,1,0,22,22,1,0,20,1,22,0,0,22,0,0,22,0,1,21,1,0,0,22,22,0,22,0,22,22,0,21,1,41,1,2,1,0,0,0,21,22,1,0,22,22,1,0,22,0,0,0,22,1],"texts":["已","创建"," `","von","-","neum","ann",".svg","`",",","一个","简洁","的","冯","诺","依","曼","架构","示意图",",","包含",":\n\n","-"," **","CPU","(","中央","处理器",")","**",":","内含","控制","单元"," CU","、","算术","逻辑","单元"," AL","U"," ","和","寄存器","组","\n","-"," **","内","存储器"," Memory","**",":","标注","\"","指令","和数据","统一","存放","\"","(","存储","程序","思想",")\n","-"," **","输入","设备"," /"," ","输出","设备","**",":","分别","用","绿色","和","粉色","表示","数据","流向","\n","-"," **","总线","连接","**",":","CPU"," ","与","内存","间的","地址","总线",",","以及"," CPU"," ","与"," I","/O"," ","设备","间的","数据","/","控制","线",",","箭头","标明","数据","流向","\n\n","下方","附","一行","核心","思想","说明",":","指令","与","数据","以","二进制","统一","存储在","存储器","中","。","用","浏览器","打开","该"," SVG"," ","文件","即可","查看","。"]},{"type":"chunk","time":1788926692044,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"已创建 `von-neumann.svg`,一个简洁的冯诺依曼架构示意图,包含:\n\n- **CPU(中央处理器)**:内含控制单元 CU、算术逻辑单元 ALU 和寄存器组\n- **内存储器 Memory**:标注\"指令和数据统一存放\"(存储程序思想)\n- **输入设备 / 输出设备**:分别用绿色和粉色表示数据流向\n- **总线连接**:CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向\n\n下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。"}}},{"type":"chunk","time":1788926692044,"chunk":{"type":"usage","usage":{"inputTokens":56,"outputTokens":145,"totalTokens":10825,"cacheReadTokens":10624,"reasoningTokens":0}}},{"type":"chunk","time":1788926692044,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"已创建 `von-neumann.svg`,一个简洁的冯诺依曼架构示意图,包含:\n\n- **CPU(中央处理器)**:内含控制单元 CU、算术逻辑单元 ALU 和寄存器组\n- **内存储器 Memory**:标注\"指令和数据统一存放\"(存储程序思想)\n- **输入设备 / 输出设备**:分别用绿色和粉色表示数据流向\n- **总线连接**:CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向\n\n下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":56,"outputTokens":145,"totalTokens":10825,"cacheReadTokens":10624,"reasoningTokens":0},"stream":[{"type":"chunk","time":1789457782649,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789457782649,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["已","创建"," `","von","-","neum","ann",".svg","`",",","一个","简洁","的","冯","诺","依","曼","架构","示意图",",","包含",":\n\n","-"," **","CPU","(","中央","处理器",")","**",":","内含","控制","单元"," CU","、","算术","逻辑","单元"," AL","U"," ","和","寄存器","组","\n","-"," **","内","存储器"," Memory","**",":","标注","\"","指令","和数据","统一","存放","\"","(","存储","程序","思想",")\n","-"," **","输入","设备"," /"," ","输出","设备","**",":","分别","用","绿色","和","粉色","表示","数据","流向","\n","-"," **","总线","连接","**",":","CPU"," ","与","内存","间的","地址","总线",",","以及"," CPU"," ","与"," I","/O"," ","设备","间的","数据","/","控制","线",",","箭头","标明","数据","流向","\n\n","下方","附","一行","核心","思想","说明",":","指令","与","数据","以","二进制","统一","存储在","存储器","中","。","用","浏览器","打开","该"," SVG"," ","文件","即可","查看","。"]},{"type":"chunk","time":1789457782650,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"已创建 `von-neumann.svg`,一个简洁的冯诺依曼架构示意图,包含:\n\n- **CPU(中央处理器)**:内含控制单元 CU、算术逻辑单元 ALU 和寄存器组\n- **内存储器 Memory**:标注\"指令和数据统一存放\"(存储程序思想)\n- **输入设备 / 输出设备**:分别用绿色和粉色表示数据流向\n- **总线连接**:CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向\n\n下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。"}}},{"type":"chunk","time":1789457782650,"chunk":{"type":"usage","usage":{"inputTokens":56,"outputTokens":145,"totalTokens":10825,"cacheReadTokens":10624,"reasoningTokens":0}}},{"type":"chunk","time":1789457782650,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} +{"type":"workspace/changes","data":{"turn":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index ac6f132b77..e40d5ed75a 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -49,6 +49,10 @@ - strong: 总线连接 - text: :CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向 - paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。 +- text: 已编辑 1 个文件 +83 -0 +- list: + - listitem: + - button "在侧边栏打开 von-neumann.svg": von-neumann.svg +83 -0 - text: 此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览 - button "在侧边栏预览 von-neumann.svg" - text: von-neumann.svg 冯诺依曼架构示意图 SVG From da6e3f9edf7a31e47a5495ae15e6f2516587e2cd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 15 Sep 2026 16:31:24 +0800 Subject: [PATCH 22/37] fix(workspace-changes): count an appended line as one addition The file tools persist both sides of a hunk without a trailing newline, so re-diffing an append read the previous last line as rewritten and reported +2 -1 for +1 -0. Both sides are line-terminated before the diff, the rule the per-call diff card already applies. --- .../deliverables/workspace-changes/src/numstat.ts | 13 +++++++++++-- .../workspace-changes/tests/numstat.spec.ts | 7 +++++++ .../workspace-changes/tests/plugin.spec.ts | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/deliverables/workspace-changes/src/numstat.ts b/packages/deliverables/workspace-changes/src/numstat.ts index 1e57db1072..cbc297b0fe 100644 --- a/packages/deliverables/workspace-changes/src/numstat.ts +++ b/packages/deliverables/workspace-changes/src/numstat.ts @@ -39,10 +39,19 @@ export function parseNumstat(output: string): NumstatEntry[] { return entries } +/** + * A side's text with every line terminated, so the last line compares by + * content alone: the file tools persist hunk sides without a trailing newline, + * and the same rule reads empty text as no lines rather than one empty line. + */ +function terminated(text: string): string { + return text === '' || text.endsWith('\n') ? text : `${text}\n` +} + /** * Sum the added and deleted lines over recorded hunks. Context lines appear on * both sides of a hunk and cancel out; a hunk without prior text counts every - * line as added. + * line as added; a trailing newline never counts as a changed line. * @param diffs - the applied hunks a file tool persisted with its result. * @returns line totals for one file. */ @@ -50,7 +59,7 @@ export function hunkLineCounts(diffs: readonly FileDiff[]): { added: number; del let added = 0 let deleted = 0 for (const diff of diffs) { - const patch = structuredPatch('', '', diff.oldText ?? '', diff.newText) + const patch = structuredPatch('', '', terminated(diff.oldText ?? ''), terminated(diff.newText)) for (const hunk of patch.hunks) { for (const line of hunk.lines) { if (line.startsWith('+')) added += 1 diff --git a/packages/deliverables/workspace-changes/tests/numstat.spec.ts b/packages/deliverables/workspace-changes/tests/numstat.spec.ts index 17157e5743..dec6e0f739 100644 --- a/packages/deliverables/workspace-changes/tests/numstat.spec.ts +++ b/packages/deliverables/workspace-changes/tests/numstat.spec.ts @@ -30,6 +30,13 @@ describe('hunkLineCounts', () => { { path: 'a', oldText: null, newText: 'x\ny\n' }, ])).toEqual({ added: 4, deleted: 1 }) }) + + it('appends a line after an unterminated last line without counting that line as rewritten', () => { + // The file tools persist hunk sides without a trailing newline; an append reads +1 -0, not +2 -1. + expect(hunkLineCounts([{ path: 'a', oldText: 'last', newText: 'last\nadded' }])).toEqual({ added: 1, deleted: 0 }) + expect(hunkLineCounts([{ path: 'a', oldText: 'last\n', newText: 'last\nadded\n' }])).toEqual({ added: 1, deleted: 0 }) + expect(hunkLineCounts([{ path: 'a', oldText: 'gone', newText: '' }])).toEqual({ added: 0, deleted: 1 }) + }) }) describe('fileDiffsOf', () => { diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index b2c627b3b4..6e762b92b3 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/packages/deliverables/workspace-changes/tests/plugin.spec.ts @@ -259,7 +259,7 @@ describe('workspace-changes without a repository', () => { expect(changes(ctx, session)).toEqual([{ turn: 1, cwd, total: 2, files: [ - { path: 'existing.txt', display: 'existing.txt', added: 4, deleted: 2 }, + { path: 'existing.txt', display: 'existing.txt', added: 3, deleted: 1 }, { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 2, deleted: 0 }, ], }]) From c8bc10440be3d1fa494e025a373f9c6d4e7f0589 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 15 Sep 2026 17:28:14 +0800 Subject: [PATCH 23/37] fix(workspace-changes): review round on the served summaries A repository whose baseline snapshot failed records nothing instead of degrading to a file-tool summary; the write tool persists whether it created or updated, so an unchanged overwrite outside snapshot coverage no longer counts its content as added; hunks inside nested repositories are dropped before check-ignore, which refused them with exit 128 and lost the whole turn; a temporary root inside the work tree keeps the recorder's own directory out of the snapshots; numstat keeps tabs in file names; the summary carries line totals over the uncapped list and the snapshot tree ids; the client's summary store abandons reads that began before a connection reset. Docs and fixtures follow. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 2 +- .../2026-09-11-turn-changed-files-card.zh.md | 2 +- apps/web/tests/clickable-links-gallery.e2e.ts | 2 +- docs/subsystems/deliverables.i18n.yaml | 4 +- docs/subsystems/deliverables.md | 6 ++ docs/subsystems/deliverables.zh.md | 6 ++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../client/ui-deliverables/src/changes.ts | 9 +-- .../src/client/ChangedFiles.tsx | 8 +-- .../src/client/Deliverables.tsx | 2 +- .../src/client/changes-summary.ts | 14 +++-- .../ui-deliverables/src/present-open.ts | 4 +- .../tests/changes-open.host.spec.ts | 22 ++++--- .../tests/deliverables.client.spec.tsx | 35 +++++++++-- packages/deliverables/README.i18n.yaml | 4 +- packages/deliverables/README.md | 2 +- packages/deliverables/README.zh.md | 2 +- .../workspace-changes/README.i18n.yaml | 4 +- .../deliverables/workspace-changes/README.md | 1 + .../workspace-changes/README.zh.md | 1 + .../deliverables/workspace-changes/src/git.ts | 53 +++++++++++++---- .../workspace-changes/src/numstat.ts | 17 ++++-- .../workspace-changes/src/recorder.ts | 58 +++++++++++++------ .../workspace-changes/src/types.ts | 6 ++ .../workspace-changes/tests/git.spec.ts | 28 ++++++++- .../tests/loader-composition.spec.ts | 6 +- .../workspace-changes/tests/numstat.spec.ts | 15 +++++ .../workspace-changes/tests/plugin.spec.ts | 32 +++++++++- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/fs/tool-fs/src/diff.ts | 6 +- packages/fs/tool-fs/src/write.ts | 1 + packages/fs/tool-fs/tests/tools.spec.ts | 7 ++- .../fs-escalation-approved/session.v3.jsonl | 2 +- .../fs-delete-recreate/session.v3.jsonl | 2 +- .../session.v3.jsonl | 2 +- .../fs-write-overwrite/session.v3.jsonl | 2 +- snapshots/session/fs-write/session.v3.jsonl | 2 +- .../session-sandbox-root/session.v3.jsonl | 2 +- .../web/changed-files-turn/session.v3.jsonl | 4 +- .../session.v3.jsonl | 2 +- snapshots/web/present-svg/session.v3.jsonl | 2 +- 44 files changed, 287 insertions(+), 106 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index 006cc6acb4..533905188a 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: b8c6ad42cb7baab26cdc9d8bcda2b4ec672afe12 -2026-09-11-turn-changed-files-card.zh.md: 4687b74a18c00b2e5b30cef42ceefb54c912c2a0 +2026-09-11-turn-changed-files-card.md: 37efc24b5eaa0a20af677a0e15977e3653e2fb66 +2026-09-11-turn-changed-files-card.zh.md: ae45644f90ab536d254ae83f68f4450edcd6b01e diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index b8c6ad42cb..37efc24b5e 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -48,6 +48,6 @@ The log deliberately carries nothing but the turn number. Summaries, snapshot tr Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the Session's temporary directory, which disposal removes. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. -The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card only when their workspace is a git repository, which one dedicated scenario seeds. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. +The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card whenever a turn changes a file; one dedicated scenario seeds a git repository so the card also carries a shell edit, while the others list their file-tool writes alone. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. Focused tests cover repositories with real git, directories outside any repository, coverage classification with the metadata the tools really persist, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index 4687b74a18..ae45644f90 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -48,6 +48,6 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入 Session 的临时目录,释放时一并删除。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 -只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只有在工作区是 git 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 +只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只要某一轮改动了文件就会新增该事件与卡片;一个专门的场景种入 git 仓库,让卡片也带上 shell 的改动,其余场景只列它们的文件工具写入。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、按工具真实持久化的元数据做覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。 diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 263a23d9bc..869bfb30e7 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -40,7 +40,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/clickable-links-gallery', const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui.expected.md', import.meta.url)) // The golden holds the show-in-folder affordance; pin the native-opener // capability so headless Linux CI and desktop developer hosts expose the same -// UI branch (same pin as produced-files.e2e.ts, whose overlay this shares). +// UI branch. const OVERLAY = fileURLToPath(new URL('./clickable-links-gallery.overlay.yml', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'clickable-links-gallery-web-e2e' diff --git a/docs/subsystems/deliverables.i18n.yaml b/docs/subsystems/deliverables.i18n.yaml index c50a3762e3..f5d6f11736 100644 --- a/docs/subsystems/deliverables.i18n.yaml +++ b/docs/subsystems/deliverables.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/subsystems/deliverables.md -deliverables.md: 6542d402a21e064f5b4d2f6223b0a22534fe7fd3 -deliverables.zh.md: f2b4490a66ee0f068af33ae00839b1ebb23e9ea8 +deliverables.md: cf795d747576451aafb104aad930efc352000fd3 +deliverables.zh.md: e4bb8bb2e590982d714701f9ab0b1851732831d2 diff --git a/docs/subsystems/deliverables.md b/docs/subsystems/deliverables.md index 6542d402a2..cf795d7475 100644 --- a/docs/subsystems/deliverables.md +++ b/docs/subsystems/deliverables.md @@ -53,6 +53,12 @@ interface WorkspaceChangesSummary { files: WorkspaceChangedFile[] /** Complete changed-file count, including files omitted by the cap. */ total: number + /** Lines added over every changed file, including files omitted by the cap. */ + added: number + /** Lines deleted over every changed file, including files omitted by the cap. */ + deleted: number + /** Git tree ids of the turn-start and turn-end snapshots; absent when no snapshot was taken. */ + snapshot?: { before: string; after: string } } ``` diff --git a/docs/subsystems/deliverables.zh.md b/docs/subsystems/deliverables.zh.md index f2b4490a66..e4bb8bb2e5 100644 --- a/docs/subsystems/deliverables.zh.md +++ b/docs/subsystems/deliverables.zh.md @@ -53,6 +53,12 @@ interface WorkspaceChangesSummary { files: WorkspaceChangedFile[] /** Complete changed-file count, including files omitted by the cap. */ total: number + /** Lines added over every changed file, including files omitted by the cap. */ + added: number + /** Lines deleted over every changed file, including files omitted by the cap. */ + deleted: number + /** Git tree ids of the turn-start and turn-end snapshots; absent when no snapshot was taken. */ + snapshot?: { before: string; after: string } } ``` diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 97fca61f0b..156fdfeaca 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/ui-deliverables/README.md -README.md: 5875fb131b094cf2e9bee3ec110bbd8bcdfba8e7 -README.zh.md: e3327a881cd15d13c6171a40b2cb6a537147098e +README.md: 4547d0887b069ed40686af8821214d96b28f6c00 +README.zh.md: 8d3534b9fc2b72d3308b10b62f7536a20e8c90f1 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 5875fb131b..4547d0887b 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -52,7 +52,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory. The Host reads the viewed Session header with the declaration or summary and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
      diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index e3327a881c..8d3534b9fc 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录。Host 读取声明或摘要及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
      diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts index 9a9c3aa0c0..bf42a100b1 100644 --- a/packages/client/ui-deliverables/src/changes.ts +++ b/packages/client/ui-deliverables/src/changes.ts @@ -8,8 +8,8 @@ export const CHANGED_FILES_PATH = '/api/changes.summary' /** Authenticated POST route for opening a changed file, or the changed files' common folder, on the Host desktop. */ export const CHANGES_OPEN_PATH = '/api/changes.open' -/** The summary fields the route serves; the Host keeps the working directory to itself. */ -export type ChangesSummary = Pick +/** The summary fields the route serves; the Host keeps the working directory and snapshot ids to itself. */ +export type ChangesSummary = Pick function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -30,12 +30,13 @@ export function isChangedFile(value: unknown): value is WorkspaceChangedFile { /** * Validate a summary read from the summary route. * @param value - decoded JSON. - * @returns whether the value identifies a turn, a complete file list, and the total count. + * @returns whether the value identifies a turn, a complete file list, the total count, and the line totals. */ export function isChangesSummary(value: unknown): value is ChangesSummary { if (!isRecord(value)) return false - const { turn, files, total } = value + const { turn, files, total, added, deleted } = value return Number.isSafeInteger(turn) && (turn as number) >= 1 && Number.isSafeInteger(total) + && Number.isSafeInteger(added) && Number.isSafeInteger(deleted) && Array.isArray(files) && files.every(isChangedFile) } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 29f87306b2..add98fde32 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -30,10 +30,6 @@ function gesture(phase: PresentedOpenPhase | undefined): { key: 'presented.openi } } -function sum(files: readonly { added: number; deleted: number }[], key: 'added' | 'deleted'): number { - return files.reduce((total, file) => total + file[key], 0) -} - /** Added and deleted line counts in the card's colors. */ function Counts({ added, deleted, t }: { added: number; deleted: number } & PropsLocale) { return <> @@ -51,7 +47,7 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop */ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openFile, t }: { /** The served summary with the sequence of the event that announced it. */ - changes: Pick & { seq: number } + changes: Pick & { seq: number } cwd: string | undefined sessionId: SessionId host: PresentedHost | null @@ -70,7 +66,7 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op {t('changes.title', { count: String(changes.total) })} {folder === undefined - ? + ? : t(folder.failed ? 'changes.folderError' : 'changes.folderOpening')} diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 05cd5d73da..f86dd79c32 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -65,7 +65,7 @@ export function Deliverables({ if (announced !== null && summary === undefined) void loadChangesSummary(sessionId, announced.seq) }, [announced, summary, sessionId, loadChangesSummary]) const changes = announced !== null && typeof summary === 'object' && summary.files.length > 0 - ? { seq: announced.seq, files: summary.files, total: summary.total } + ? { seq: announced.seq, ...summary } : null const collapsible = matched.presented.length > COLLAPSED_PRESENTED_COUNT const presented = collapsible && !expanded diff --git a/packages/client/ui-deliverables/src/client/changes-summary.ts b/packages/client/ui-deliverables/src/client/changes-summary.ts index 1aca9131e9..5dd4b37be8 100644 --- a/packages/client/ui-deliverables/src/client/changes-summary.ts +++ b/packages/client/ui-deliverables/src/client/changes-summary.ts @@ -11,6 +11,8 @@ export class ChangesSummaryStore { /** Summary URLs key the state across Sessions and turns. */ readonly state = createSnapshotStore>({}) private readonly lifetime = new AbortController() + /** The connection generation the current states belong to; a reset aborts it so no older read publishes. */ + private generation = new AbortController() private readonly pending = new Set>() /** @@ -23,7 +25,7 @@ export class ChangesSummaryStore { const url = changesSummaryUrl(sessionId, seq) if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] !== undefined) return this.state.update((state) => { state[url] = 'loading' }) - const task = this.read(url) + const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal])) this.pending.add(task) try { await task @@ -32,8 +34,10 @@ export class ChangesSummaryStore { } } - /** Forget every state; a replaced connection may reach a Host that no longer serves them. */ + /** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */ reset(): void { + this.generation.abort() + this.generation = new AbortController() this.state.set({}) } @@ -43,10 +47,10 @@ export class ChangesSummaryStore { await Promise.all(this.pending) } - private async read(url: string): Promise { + private async read(url: string, signal: AbortSignal): Promise { let next: ChangesSummaryState = 'missing' try { - const response = await fetch(url, { signal: this.lifetime.signal }) + const response = await fetch(url, { signal }) if (response.ok) { const value: unknown = await response.json() if (isChangesSummary(value)) next = value @@ -55,6 +59,6 @@ export class ChangesSummaryStore { // A transport failure reads as missing until the connection is replaced and the store reset. next = 'missing' } - if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = next }) + if (!signal.aborted) this.state.update((state) => { state[url] = next }) } } diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 7fb1da65a2..193694b3eb 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -148,8 +148,8 @@ function handleChangesSummary(ctx: Context, request: Request): Response { if (!id || seq === undefined) return new Response('Invalid change summary coordinates.', { status: 400 }) const summary = ctx.workspaceChanges.summary(id as SessionId, seq) if (summary === undefined) return new Response('Change summary unavailable.', { status: 404 }) - const { turn, files, total } = summary - return Response.json({ turn, files, total } satisfies ChangesSummary, { headers: { 'cache-control': 'no-store' } }) + const { turn, files, total, added, deleted } = summary + return Response.json({ turn, files, total, added, deleted } satisfies ChangesSummary, { headers: { 'cache-control': 'no-store' } }) } async function handleChangesOpen(ctx: Context, request: Request): Promise { diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts index cade0dd40e..844fa14678 100644 --- a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -33,7 +33,10 @@ async function fixture() { await writeFile(join(cwd, 'src', 'b.ts'), 'b') const outside = join(root, 'outside.txt') await writeFile(outside, 'outside') - const data: WorkspaceChangesSummary = { turn: 1, cwd, total: 3, files: [changed('src/lib/a.ts'), changed('src/b.ts'), changed(outside, '~/outside.txt')] } + const data: WorkspaceChangesSummary = { + turn: 1, cwd, total: 3, added: 3, deleted: 0, snapshot: { before: 'a'.repeat(40), after: 'b'.repeat(40) }, + files: [changed('src/lib/a.ts'), changed('src/b.ts'), changed(outside, '~/outside.txt')], + } const ctx = new Context() cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(LocalFileSystem, { cwd }) @@ -68,7 +71,8 @@ describe('change summary route', () => { const response = await read() expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') - expect(await response.json()).toEqual({ turn: 1, total: 3, files: data.files }) + // The working directory and snapshot ids stay on the Host. + expect(await response.json()).toEqual({ turn: 1, total: 3, added: 3, deleted: 0, files: data.files }) expect((await read('?sessionId=owner&seq=8')).status).toBe(404) expect((await read('?sessionId=other&seq=9')).status).toBe(404) for (const bad of ['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=x', '?sessionId=owner&seq=1.5']) { @@ -147,12 +151,14 @@ describe('changed files native open route', () => { expect(isChangedFile({ path: 'a', display: '', added: 1, deleted: 2 })).toBe(false) expect(isChangedFile({ path: 'a', display: 'a', added: 1.5, deleted: 2 })).toBe(false) expect(isChangedFile([])).toBe(false) - expect(isChangesSummary({ turn: 1, total: 0, files: [] })).toBe(true) - expect(isChangesSummary({ turn: 1, total: 1, files: [{ path: 'a', display: 'a', added: 1, deleted: 0 }] })).toBe(true) - expect(isChangesSummary({ turn: '1', total: 0, files: [] })).toBe(false) - expect(isChangesSummary({ turn: 0, total: 0, files: [] })).toBe(false) - expect(isChangesSummary({ turn: 1, total: 1.5, files: [] })).toBe(false) - expect(isChangesSummary({ turn: 1, total: 1, files: [{ path: 'a' }] })).toBe(false) + expect(isChangesSummary({ turn: 1, total: 0, added: 0, deleted: 0, files: [] })).toBe(true) + expect(isChangesSummary({ turn: 1, total: 1, added: 1, deleted: 0, files: [{ path: 'a', display: 'a', added: 1, deleted: 0 }] })).toBe(true) + expect(isChangesSummary({ turn: '1', total: 0, added: 0, deleted: 0, files: [] })).toBe(false) + expect(isChangesSummary({ turn: 0, total: 0, added: 0, deleted: 0, files: [] })).toBe(false) + expect(isChangesSummary({ turn: 1, total: 1.5, added: 0, deleted: 0, files: [] })).toBe(false) + expect(isChangesSummary({ turn: 1, total: 0, files: [] })).toBe(false) + expect(isChangesSummary({ turn: 1, total: 0, added: 0, deleted: 'x', files: [] })).toBe(false) + expect(isChangesSummary({ turn: 1, total: 1, added: 1, deleted: 0, files: [{ path: 'a' }] })).toBe(false) expect(isChangesSummary([])).toBe(false) expect(isChangesEvent({ turn: 1 })).toBe(true) expect(isChangesEvent({ turn: 1, extra: true })).toBe(true) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index a6c0e52718..95aa2c265c 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -469,7 +469,7 @@ describe('ChangedFiles card', () => { changedFile('~/.zshrc', 0, 0, { binary: true, path: '/home/u/.zshrc' }), ] const changes = { seq: 5 } - const served: ChangesSummary = { turn: 1, files, total: 11 } + const served: ChangesSummary = { turn: 1, files, total: 11, added: 1232, deleted: 326 } /** A store already holding the summary the Host served for the announced sequence. */ function servedStore(summary: ChangesSummary = served, seq = 5) { @@ -493,7 +493,7 @@ describe('ChangedFiles card', () => { const fetchMock = vi.fn(async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url if (url.endsWith('seq=5')) return Response.json(served) - if (url.endsWith('seq=6')) return Response.json({ turn: 1, files: [], total: 0 }) + if (url.endsWith('seq=6')) return Response.json({ turn: 1, files: [], total: 0, added: 0, deleted: 0 }) return new Response('gone', { status: 404 }) }) vi.stubGlobal('fetch', fetchMock) @@ -526,6 +526,29 @@ describe('ChangedFiles card', () => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 9)]).toBeUndefined() }) + it('sums the header from the Host totals, not from the capped list', () => { + const capped = servedStore({ turn: 1, files: files.slice(0, 1), total: 2, added: 50, deleted: 20 }) + const { view } = renderCard(new PresentedOpenController(), en, { changes, presented: [] as never[] }, capped) + expect(view.getByText('Edited 2 files')).toBeTruthy() + expect(view.getByText('+50')).toBeTruthy() + expect(view.getByText('-20')).toBeTruthy() + }) + + it('lets no read started before a reset publish afterwards', async () => { + const summaries = new ChangesSummaryStore() + let settle!: (response: Response) => void + vi.stubGlobal('fetch', vi.fn(() => new Promise((resolve) => { settle = resolve }))) + const stale = summaries.load(SessionId('child-session'), 5) + summaries.reset() + const url = changesSummaryUrl(SessionId('child-session'), 5) + expect(summaries.state.getSnapshot()[url]).toBeUndefined() + settle(Response.json(served)) + await stale + // The reset abandoned the read; a later mount asks the new Host afresh. + expect(summaries.state.getSnapshot()[url]).toBeUndefined() + await summaries.dispose() + }) + it('drops a read that settles after disposal', async () => { const summaries = new ChangesSummaryStore() let settle!: (response: Response) => void @@ -618,7 +641,7 @@ describe('ChangedFiles card', () => { }) it('renders without a fold for three files or fewer and beside delivery cards', () => { - const short = servedStore({ turn: 1, files: files.slice(0, 2), total: 2 }) + const short = servedStore({ turn: 1, files: files.slice(0, 2), total: 2, added: 185, deleted: 43 }) const { view } = renderCard(new PresentedOpenController(), en, { changes, presented: [{ path: 'report.pdf', seq: 6, index: 0 }] as never[] }, short) expect(view.getByText('Edited 2 files')).toBeTruthy() expect(view.queryByRole('button', { name: /Show all|Collapse changed/ })).toBeNull() @@ -716,9 +739,9 @@ describe('plugin registration', () => { fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) await face.reloadPresentedHost() expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' }) - fetcher.mockResolvedValueOnce(Response.json({ turn: 1, files: [], total: 0 })) + fetcher.mockResolvedValueOnce(Response.json({ turn: 1, files: [], total: 0, added: 0, deleted: 0 })) await face.loadChangesSummary(SessionId('child-session'), 5) - expect(face.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0 }) + expect(face.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0, added: 0, deleted: 0 }) ctx.emit('connection/reset') expect(face.hooks.presentedHost.getSnapshot()).toBeNull() // The replaced connection may reach a Host that no longer serves the summaries read so far. @@ -814,7 +837,7 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] }, const owner = tailOwner(deliverablesOf(value), 6) const matched = selectDeliverables(owner)! const summaries = new ChangesSummaryStore() - summaries.state.set({ [changesSummaryUrl(SessionId('session'), 5)]: { turn: 1, files: [{ path: 'a.txt', display: 'a.txt', added: 1, deleted: 0 }], total: 1 } }) + summaries.state.set({ [changesSummaryUrl(SessionId('session'), 5)]: { turn: 1, files: [{ path: 'a.txt', display: 'a.txt', added: 1, deleted: 0 }], total: 1, added: 1, deleted: 0 } }) const view = render() expect(view.getByText('Edited 1 files')).toBeTruthy() expect(view.queryByText('Deliverables')).toBeNull() diff --git a/packages/deliverables/README.i18n.yaml b/packages/deliverables/README.i18n.yaml index bf5f2f81a2..89304481c4 100644 --- a/packages/deliverables/README.i18n.yaml +++ b/packages/deliverables/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/deliverables/README.md -README.md: 2d3a6908798a2e1565186a016d33d1a806f256d7 -README.zh.md: b9d000c923028168ba818ff995a25372f2203a93 +README.md: 92b9dfa95c4b5ded19fa2753d51551bafbd03fed +README.zh.md: ff8e0fe0eba4ed02504e30765ede58c95b78daa0 diff --git a/packages/deliverables/README.md b/packages/deliverables/README.md index 2d3a690879..92b9dfa95c 100644 --- a/packages/deliverables/README.md +++ b/packages/deliverables/README.md @@ -25,7 +25,7 @@ The deliverables family records what a turn hands to the user as durable Session | Package | Role | ctx key | |---|---|---| | [`tool-present`](tool-present/README.md) | Declares existing files as final deliverables through the `present` tool | registers on `ctx.tools` | -| [`workspace-changes`](workspace-changes/README.md) | Records each top-level turn's changed files from git working-tree snapshots | listens to `session/event`, appends `workspace/changes` | +| [`workspace-changes`](workspace-changes/README.md) | Summarizes each top-level turn's changed files from git working-tree snapshots | provides `ctx.workspaceChanges`; listens to `session/event`, appends `workspace/changes` | ----- diff --git a/packages/deliverables/README.zh.md b/packages/deliverables/README.zh.md index b9d000c923..ff8e0fe0eb 100644 --- a/packages/deliverables/README.zh.md +++ b/packages/deliverables/README.zh.md @@ -25,7 +25,7 @@ deliverables 系列把一轮交给用户的内容记录为只有客户端读取 | 包 | 角色 | ctx key | |---|---|---| | [`tool-present`](tool-present/README.zh.md) | 通过 `present` 工具把已有文件声明为最终交付物 | 注册到 `ctx.tools` | -| [`workspace-changes`](workspace-changes/README.zh.md) | 用 git 工作树快照记录每个顶层轮次改动的文件 | 监听 `session/event`,追加 `workspace/changes` | +| [`workspace-changes`](workspace-changes/README.zh.md) | 用 git 工作树快照汇总每个顶层轮次改动的文件 | 提供 `ctx.workspaceChanges`;监听 `session/event`,追加 `workspace/changes` | ----- diff --git a/packages/deliverables/workspace-changes/README.i18n.yaml b/packages/deliverables/workspace-changes/README.i18n.yaml index da91f3fdc5..c60f0362fe 100644 --- a/packages/deliverables/workspace-changes/README.i18n.yaml +++ b/packages/deliverables/workspace-changes/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/deliverables/workspace-changes/README.md -README.md: 82106aa6c7fa7b812a228fc80f693d9fbee1ab5b -README.zh.md: 31503458242cf42a93483b2a81537058516719b5 +README.md: 5c304e3112c4d58a5baec54547e7341fde8e1dec +README.zh.md: 428c3ce3d912cc17018cc39f552f1748b5ae56ff diff --git a/packages/deliverables/workspace-changes/README.md b/packages/deliverables/workspace-changes/README.md index 82106aa6c7..5c304e3112 100644 --- a/packages/deliverables/workspace-changes/README.md +++ b/packages/deliverables/workspace-changes/README.md @@ -86,6 +86,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected. - Summaries and snapshot trees live only as long as their Session in this Host process; earlier turns of a conversation reopened after a Host restart have no card. This is the decided behavior: a card whose content the Host can no longer open is not shown. - Two git features still write into the repository's own git directory during a snapshot: `core.splitIndex` writes `sharedindex.*` files, and git-lfs runs its clean filter on changed files and stores their objects under `.git/lfs`. - git 2.13 or later is required for `rev-parse --absolute-git-dir`; an unsupported repository format or another git failure abandons the turn with a warning rather than being treated as a plain directory. +- The first snapshot of a Session writes every untracked, non-ignored file of the work tree into the Session's temporary directory; a repository without a `.gitignore` that carries large build outputs costs that much temporary space until the Session is disposed. - Edits the user makes during a turn are attributed to that turn. - A working directory outside any git repository lists file-tool edits only, so shell edits are missing from its card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably. - Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only. diff --git a/packages/deliverables/workspace-changes/README.zh.md b/packages/deliverables/workspace-changes/README.zh.md index 3150345824..428c3ce3d9 100644 --- a/packages/deliverables/workspace-changes/README.zh.md +++ b/packages/deliverables/workspace-changes/README.zh.md @@ -86,6 +86,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ - 摘要与快照树只在本 Host 进程内随其 Session 存活;Host 重启后重新打开的对话,先前轮次没有卡片。这是既定行为:Host 已经打不开内容的卡片不显示。 - 有两个 git 功能在快照期间仍会写入仓库自己的 git 目录:`core.splitIndex` 会写 `sharedindex.*` 文件,git-lfs 会对改动文件运行 clean 过滤器并把对象存到 `.git/lfs` 下。 - 需要 git 2.13 或更高版本以支持 `rev-parse --absolute-git-dir`;不支持的仓库格式或其他 git 失败会带着警告放弃本轮,而不是被当成普通目录。 +- Session 的首次快照会把工作树里所有未跟踪且未被忽略的文件写进 Session 的临时目录;没有 `.gitignore` 却带着大体积构建产物的仓库,在 Session 释放前会占用同等的临时空间。 - 用户在轮次进行中自己做的编辑会被算到该轮。 - 不在任何 git 仓库内的工作目录只列文件工具的编辑,卡片里因此没有 shell 改动;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。 - 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。 diff --git a/packages/deliverables/workspace-changes/src/git.ts b/packages/deliverables/workspace-changes/src/git.ts index a0cb5c0171..0722690a1c 100644 --- a/packages/deliverables/workspace-changes/src/git.ts +++ b/packages/deliverables/workspace-changes/src/git.ts @@ -1,9 +1,9 @@ /** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */ import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join, relative, resolve } from 'node:path' import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' import { parseNumstat, type NumstatEntry } from './numstat.ts' +import { canonicalPath, isInside, toPosix } from './paths.ts' /** Milliseconds a git child gets to exit after termination starts; a fixed lifecycle constant. */ const TERMINATE_GRACE_MS = 2_000 @@ -88,14 +88,18 @@ function ok(result: GitRunResult, what: string): GitRunResult { return result } -/** The repository enclosing a Session working directory and the private store its snapshots write to. */ +/** The repository enclosing a Session working directory and the private directory its snapshots write to. */ export interface GitWorkspace { /** Repository top-level directory, the root every diff path is relative to. */ root: string /** Absolute git directory holding the repository's index. */ gitDir: string + /** Private directory holding the snapshot object store and each snapshot's scratch index. */ + scratch: string /** Environment that routes object writes to the private store and object reads through the repository's store. */ env: Readonly> + /** Work-tree paths a snapshot must skip: the private directory when a temporary root lies inside the work tree. */ + excludes: readonly string[] } /** Whether a filesystem error names a missing path. */ @@ -105,26 +109,32 @@ function isMissing(error: unknown): boolean { /** * Locate the repository enclosing a working directory and prepare the private - * object store its snapshots write to. The repository's own object store is + * directory its snapshots write to. The repository's own object store is * attached read-only as an alternate, so snapshots read committed content from - * it and write nothing into it. A directory outside any repository yields - * null; any other git failure throws. + * it and write nothing into it. A private directory that lies inside the work + * tree, as a temporary root under the workspace does, is excluded from every + * snapshot. A directory outside any repository yields null; any other git + * failure throws. * @param git - command runner. * @param cwd - absolute Session working directory. - * @param objectsDir - yields the directory that receives every snapshot blob and tree; called only for a located repository. + * @param scratch - yields the private directory for snapshot objects and scratch indexes; called only for a located repository. * @param signal - cancellation. * @returns the repository, or null when the directory is not inside one. */ export async function locateGitWorkspace( - git: GitRunner, cwd: string, objectsDir: () => Promise, signal: AbortSignal, + git: GitRunner, cwd: string, scratch: () => Promise, signal: AbortSignal, ): Promise { const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-path', 'objects'], { cwd, signal }) if (found.exitCode === 128 && /not a git repository/i.test(found.stderr)) return null const lines = ok(found, 'git rev-parse').stdout.split('\n').map(line => resolve(cwd, line)) const [root, gitDir, repositoryObjects] = lines as [string, string, string] - const objects = await objectsDir() + // git reports the canonical root; compare the private directory in the same spelling. + const directory = await canonicalPath(await scratch()) + const objects = join(directory, 'objects') await mkdir(objects, { recursive: true }) - return { root, gitDir, env: { GIT_OBJECT_DIRECTORY: objects, GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects } } + const excludes = isInside(root, directory) ? [toPosix(relative(root, directory))] : [] + const env = { GIT_OBJECT_DIRECTORY: objects, GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects } + return { root, gitDir, scratch: directory, env, excludes } } /** @@ -139,7 +149,7 @@ export async function locateGitWorkspace( * @returns the tree object id. */ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, signal: AbortSignal): Promise { - const scratch = await mkdtemp(join(tmpdir(), 'dsh-workspace-changes-')) + const scratch = await mkdtemp(join(workspace.scratch, 'index-')) try { const index = join(scratch, 'index') // A repository without an index yet (fresh `git init`) starts from scratch. @@ -148,7 +158,8 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign }) const env = { ...workspace.env, GIT_INDEX_FILE: index } // `--ignore-errors` skips unreadable files and reports them through exit code 1; the index is still complete. - const added = await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }) + const pathspec = workspace.excludes.length === 0 ? [] : ['--', '.', ...workspace.excludes.map(path => `:(exclude)${path}`)] + const added = await git.run(['add', '--all', '--ignore-errors', ...pathspec], { cwd: workspace.root, env, signal }) /* v8 ignore next -- git reports a skipped unreadable file only on hosts whose permissions the tests can revoke. */ if (added.exitCode !== 1) ok(added, `git add in ${workspace.root}`) return ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim() @@ -178,6 +189,24 @@ export async function diffTrees( return parseNumstat(result.stdout) } +/** + * Work-tree directories the index records as gitlinks: nested repositories and + * submodules, whose contents snapshots never descend into and `check-ignore` + * refuses to classify. + * @param git - command runner. + * @param workspace - addressed repository. + * @param signal - cancellation. + * @returns slash-separated gitlink paths relative to the repository root. + */ +export async function gitlinkPaths(git: GitRunner, workspace: GitWorkspace, signal: AbortSignal): Promise> { + const result = ok(await git.run(['ls-files', '-z', '--stage'], { cwd: workspace.root, env: workspace.env, signal }), 'git ls-files') + const links = new Set() + for (const entry of result.stdout.split('\0')) { + if (entry.startsWith('160000 ')) links.add(entry.slice(entry.indexOf('\t') + 1)) + } + return links +} + /** * The subset of work-tree paths that the repository ignores. Tracked files * are never reported, so a tracked file matching an ignore pattern still diff --git a/packages/deliverables/workspace-changes/src/numstat.ts b/packages/deliverables/workspace-changes/src/numstat.ts index cbc297b0fe..79be1c504a 100644 --- a/packages/deliverables/workspace-changes/src/numstat.ts +++ b/packages/deliverables/workspace-changes/src/numstat.ts @@ -24,8 +24,13 @@ export function parseNumstat(output: string): NumstatEntry[] { const entries: NumstatEntry[] = [] while (queue.length > 0) { const record = queue.shift() as string - const [added, deleted, path] = record.split('\t') - if (added === undefined || deleted === undefined || path === undefined) throw new Error(`malformed numstat record: ${record}`) + // Only the first two tabs separate fields; a file name keeps its own tabs. + const first = record.indexOf('\t') + const second = first < 0 ? -1 : record.indexOf('\t', first + 1) + if (second < 0) throw new Error(`malformed numstat record: ${record}`) + const added = record.slice(0, first) + const deleted = record.slice(first + 1, second) + const path = record.slice(second + 1) let target = path if (target === '') { queue.shift() @@ -73,12 +78,14 @@ export function hunkLineCounts(diffs: readonly FileDiff[]): { added: number; del /** * Narrow a tool result's opaque `meta` to the file-tool hunk list. * @param meta - persisted result metadata. - * @returns the hunks, or undefined when the metadata carries none; `write` persists an empty list for a created file. + * @returns the hunks; an empty list for a `write` that updated a file without changing it; undefined when the + * metadata carries none, as `write` persists for a created file, so the call's arguments supply the content. */ export function fileDiffsOf(meta: unknown): FileDiff[] | undefined { if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined - const { diffs } = meta as Record - if (!Array.isArray(diffs) || diffs.length === 0) return undefined + const { diffs, operation } = meta as Record + if (!Array.isArray(diffs)) return undefined + if (diffs.length === 0) return operation === 'update' ? [] : undefined const out: FileDiff[] = [] for (const value of diffs) { if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined diff --git a/packages/deliverables/workspace-changes/src/recorder.ts b/packages/deliverables/workspace-changes/src/recorder.ts index b2c6afe223..f62630c595 100644 --- a/packages/deliverables/workspace-changes/src/recorder.ts +++ b/packages/deliverables/workspace-changes/src/recorder.ts @@ -4,7 +4,7 @@ import { homedir } from 'node:os' import { join, relative, resolve } from 'node:path' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts' +import { diffTrees, gitlinkPaths, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts' import { argumentHunks, fileDiffsOf, hunkLineCounts } from './numstat.ts' import { canonicalPath, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, temporaryRoots, toPosix } from './paths.ts' import type { WorkspaceChangedFile, WorkspaceChangesSummary } from './types.ts' @@ -43,8 +43,11 @@ interface Baseline extends Repository { tree: string } /** Everything one turn accumulates; a new turn gets a new object so queued work for an older turn keeps its own. */ interface TurnState { readonly turn: number - /** Set once the turn-start snapshot exists; a turn without one summarizes file-tool hunks only. */ - baseline: Baseline | null + /** + * The turn-start snapshot once it exists. `null` means no repository or no git, so the turn summarizes file-tool + * hunks only; `'failed'` means the repository exists but its snapshot failed, so the turn records nothing. + */ + baseline: Baseline | null | 'failed' /** Hunks derived from each mutation call's arguments, or null for a call that changes no file. */ readonly calls: Map /** File-tool hunks by their model-facing path; canonicalized when the record is built. */ @@ -77,8 +80,8 @@ export class TurnRecorder { private paths: Paths | undefined /** The located repository, reused across turns once found; null keeps retrying each turn. */ private repository: Repository | null = null - /** Temporary directory holding this Session's snapshot objects, created with the first located repository. */ - private objectsDir: string | undefined + /** Temporary directory holding this Session's snapshot objects and scratch indexes, created with the first located repository. */ + private scratch: string | undefined /** Summaries by the sequence of the event that announced them. */ private readonly summaries = new Map() private readonly lifetime = new AbortController() @@ -97,11 +100,17 @@ export class TurnRecorder { const state = freshState(turn) this.state = state void this.enqueue(async (signal) => { - this.paths ??= { cwd: await realpath(this.cwd), home: await canonicalPath(homedir()), temporaryRoots: await temporaryRoots() } - const repository = await this.locate(this.paths.cwd, signal) - if (repository === null) return - const tree = await snapshotTree(repository.git, repository.workspace, signal) - state.baseline = { ...repository, tree } + try { + this.paths ??= { cwd: await realpath(this.cwd), home: await canonicalPath(homedir()), temporaryRoots: await temporaryRoots() } + const repository = await this.locate(this.paths.cwd, signal) + if (repository === null) return + const tree = await snapshotTree(repository.git, repository.workspace, signal) + state.baseline = { ...repository, tree } + } catch (error: unknown) { + // A repository whose snapshot failed must not be summarized as if it had none. + state.baseline = 'failed' + throw error + } }) } @@ -175,7 +184,7 @@ export class TurnRecorder { this.lifetime.abort() this.summaries.clear() await this.chain - if (this.objectsDir !== undefined) await rm(this.objectsDir, { recursive: true, force: true }) + if (this.scratch !== undefined) await rm(this.scratch, { recursive: true, force: true }) } private enqueue(task: (signal: AbortSignal) => Promise): Promise { @@ -202,8 +211,8 @@ export class TurnRecorder { const git = await this.env.git if (git === null) return null const workspace = await locateGitWorkspace(git, cwd, async () => { - this.objectsDir ??= await mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-objects-')) - return this.objectsDir + this.scratch ??= await mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-')) + return this.scratch }, signal) if (workspace === null) return null this.repository = { git, workspace } @@ -212,14 +221,16 @@ export class TurnRecorder { private async record(state: TurnState, signal: AbortSignal): Promise { const paths = this.paths - if (paths === undefined || state.lastToolResultSeq < 0) return - state.attemptedAfterSeq = state.lastToolResultSeq const { baseline } = state + if (paths === undefined || baseline === 'failed' || state.lastToolResultSeq < 0) return + state.attemptedAfterSeq = state.lastToolResultSeq // Without a snapshot the working directory itself bounds the workspace. const root = baseline?.workspace.root ?? paths.cwd const files = new Map() + let snapshot: WorkspaceChangesSummary['snapshot'] if (baseline !== null) { const after = await snapshotTree(baseline.git, baseline.workspace, signal) + snapshot = { before: baseline.tree, after } for (const entry of await diffTrees(baseline.git, baseline.workspace, baseline.tree, after, signal)) { const absolute = resolve(root, entry.path) files.set(absolute, changedFile(paths, root, absolute, entry)) @@ -232,7 +243,12 @@ export class TurnRecorder { if (!files.has(absolute)) hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list]) } const workTreePath = (absolute: string): string => toPosix(relative(root, absolute)) - const inWorkspace = [...hunks.keys()].filter(absolute => isInside(root, absolute)) + let inWorkspace = [...hunks.keys()].filter(absolute => isInside(root, absolute)) + if (baseline !== null && inWorkspace.length > 0) { + // Nested repositories and submodules are gitlinks: their contents never enter the summary. + const gitlinks = await gitlinkPaths(baseline.git, baseline.workspace, signal) + inWorkspace = inWorkspace.filter(absolute => ![...gitlinks].some(link => isInside(resolve(root, link), absolute))) + } // A snapshot covers every workspace file except the ignored ones; without one, every file-tool edit counts. const uncoveredInWorkspace = baseline === null ? new Set(inWorkspace.map(workTreePath)) @@ -248,7 +264,15 @@ export class TurnRecorder { // An empty list after an earlier in-turn record supersedes that record. if (sorted.length === 0 && state.recordedAfterSeq < 0) return const event = this.session.append('workspace/changes', { turn: state.turn }) - this.summaries.set(event.seq, { turn: state.turn, cwd: this.cwd, files: sorted.slice(0, this.env.maxFiles), total: sorted.length }) + this.summaries.set(event.seq, { + turn: state.turn, + cwd: this.cwd, + files: sorted.slice(0, this.env.maxFiles), + total: sorted.length, + added: sorted.reduce((sum, file) => sum + file.added, 0), + deleted: sorted.reduce((sum, file) => sum + file.deleted, 0), + ...snapshot === undefined ? {} : { snapshot }, + }) state.recordedAfterSeq = event.seq } } diff --git a/packages/deliverables/workspace-changes/src/types.ts b/packages/deliverables/workspace-changes/src/types.ts index 46c0352351..a1b3762927 100644 --- a/packages/deliverables/workspace-changes/src/types.ts +++ b/packages/deliverables/workspace-changes/src/types.ts @@ -29,6 +29,12 @@ export interface WorkspaceChangesSummary { files: WorkspaceChangedFile[] /** Complete changed-file count, including files omitted by the cap. */ total: number + /** Lines added over every changed file, including files omitted by the cap. */ + added: number + /** Lines deleted over every changed file, including files omitted by the cap. */ + deleted: number + /** Git tree ids of the turn-start and turn-end snapshots; absent when no snapshot was taken. */ + snapshot?: { before: string; after: string } } /** Serves the summaries the recorder keeps for live Sessions. */ diff --git a/packages/deliverables/workspace-changes/tests/git.spec.ts b/packages/deliverables/workspace-changes/tests/git.spec.ts index be5a9e53e7..7d3656c162 100644 --- a/packages/deliverables/workspace-changes/tests/git.spec.ts +++ b/packages/deliverables/workspace-changes/tests/git.spec.ts @@ -7,7 +7,7 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { GitRunner, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree } from '../src/git.ts' import { TurnRecorder } from '../src/recorder.ts' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import { git, scratchDir } from './support.ts' +import { git, scratchDir, startTurn, toolCall } from './support.ts' /** An object directory factory under a scratch root. */ const objectsIn = (root: string) => () => Promise.resolve(join(root, 'objects')) @@ -70,7 +70,7 @@ describe('snapshots and diffs', () => { const { git: runnerGit } = await runner() const store = objectsIn(await scratchDir('dsh-git-store-', cleanups)) expect(await locateGitWorkspace(runnerGit, cwd, store, signal)).toBeNull() - const broken = { root: cwd, gitDir: join(cwd, 'missing'), env: {} } + const broken = { root: cwd, gitDir: join(cwd, 'missing'), scratch: cwd, env: {}, excludes: [] } await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in') await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed') expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set()) @@ -171,10 +171,32 @@ describe('TurnRecorder', () => { const repo = new TurnRecorder(ctx.sessions.create(SessionId('repo'), { meta: { cwd } }), cwd, env) repo.start(1) await repo.settled() - const [objects, ...others] = (await readdir(tempRoot)).filter(entry => entry.startsWith('dsh-workspace-changes-objects-')) + const [objects, ...others] = (await readdir(tempRoot)).filter(entry => entry.startsWith('dsh-workspace-changes-')) expect(others).toEqual([]) expect(objects).toBeDefined() await repo.dispose() expect(await readdir(tempRoot)).toEqual([]) }) + + it('keeps its own directory out of the snapshots when the temporary root lies inside the work tree', async () => { + const cwd = await scratchDir('dsh-recorder-tmp-in-tree-', cleanups) + git(cwd, 'init', '-q', '-b', 'main') + await writeFile(join(cwd, 'tracked.txt'), 'one\n') + git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'init') + const tempRoot = join(cwd, 'tmp') + await mkdir(tempRoot) + const { ctx, git: runnerGit } = await runner() + const session = ctx.sessions.create(SessionId('tmp-in-tree'), { meta: { cwd } }) + const env = { git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, warn: (m: string) => { throw new Error(m) } } + const recorder = new TurnRecorder(session, cwd, env) + startTurn(session, 1) + recorder.start(1) + await recorder.settled() + await writeFile(join(cwd, 'tracked.txt'), 'one\ntwo\n') + recorder.observe(toolCall(session, 1, 'bash', { command: 'x' })) + await recorder.stopping(1) + const announced = session.snapshotEvents().find(event => event.type === 'workspace/changes') + expect(recorder.summary(announced!.seq)?.files.map(file => file.display)).toEqual(['tracked.txt']) + await recorder.dispose() + }) }) diff --git a/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts b/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts index d02a6fdd73..e75427e5a3 100644 --- a/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts +++ b/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts @@ -73,6 +73,10 @@ describe('real Loader composition', () => { await context.waterfall('tools/pre-execute', { agent: { session } } as never, () => Promise.resolve(undefined as never)) const [recorded, ...rest] = changes(context, session) expect(rest).toEqual([]) - expect(recorded).toEqual({ turn: 1, cwd, total: 1, files: [{ path: 'tracked.txt', display: 'tracked.txt', added: 1, deleted: 0 }] }) + expect(recorded).toEqual({ + turn: 1, cwd, total: 1, added: 1, deleted: 0, + files: [{ path: 'tracked.txt', display: 'tracked.txt', added: 1, deleted: 0 }], + snapshot: { before: expect.stringMatching(/^[0-9a-f]+$/) as string, after: expect.stringMatching(/^[0-9a-f]+$/) as string }, + }) }) }) diff --git a/packages/deliverables/workspace-changes/tests/numstat.spec.ts b/packages/deliverables/workspace-changes/tests/numstat.spec.ts index dec6e0f739..164839657b 100644 --- a/packages/deliverables/workspace-changes/tests/numstat.spec.ts +++ b/packages/deliverables/workspace-changes/tests/numstat.spec.ts @@ -23,6 +23,17 @@ describe('parseNumstat', () => { }) }) +describe('parseNumstat with unusual names', () => { + it('keeps tabs inside a file name', () => { + expect(parseNumstat(['1\t0\ta\tb.txt', '2\t0\t', 'old\tx', 'new\ty', ''].join('\u0000'))).toEqual([ + { path: 'a\tb.txt', added: 1, deleted: 0, binary: false }, + { path: 'new\ty', added: 2, deleted: 0, binary: false }, + ]) + expect(() => parseNumstat('1\ta.txt\u0000')).toThrow('malformed numstat record') + expect(() => parseNumstat('garbage\u0000')).toThrow('malformed numstat record') + }) +}) + describe('hunkLineCounts', () => { it('counts changed lines and ignores context', () => { expect(hunkLineCounts([ @@ -45,6 +56,10 @@ describe('fileDiffsOf', () => { expect(fileDiffsOf(undefined)).toBeUndefined() expect(fileDiffsOf([])).toBeUndefined() expect(fileDiffsOf({ diffs: [] })).toBeUndefined() + expect(fileDiffsOf({ diffs: 'x' })).toBeUndefined() + expect(fileDiffsOf({ operation: 'create', diffs: [] })).toBeUndefined() + // An unchanged overwrite persists no hunks and changes nothing; the call's content must not count as added. + expect(fileDiffsOf({ operation: 'update', diffs: [] })).toEqual([]) expect(fileDiffsOf({ diffs: [null] })).toBeUndefined() expect(fileDiffsOf({ diffs: [{ path: 1, oldText: null, newText: '' }] })).toBeUndefined() expect(fileDiffsOf({ diffs: [{ path: 'a', oldText: 2, newText: '' }] })).toBeUndefined() diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index 6e762b92b3..2b676f1547 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/packages/deliverables/workspace-changes/tests/plugin.spec.ts @@ -82,7 +82,9 @@ describe('workspace-changes in a repository', () => { expect(events.map(event => event.data)).toEqual([{ turn: 1 }]) const [recorded, ...rest] = changes(ctx, session) expect(rest).toEqual([]) - expect(recorded).toMatchObject({ turn: 1, cwd, total: 6 }) + expect(recorded).toMatchObject({ turn: 1, cwd, total: 6, added: 8, deleted: 1 }) + expect(recorded!.snapshot!.before).toMatch(/^[0-9a-f]{40,64}$/) + expect(recorded!.snapshot!.after).toMatch(/^[0-9a-f]{40,64}$/) expect(recorded!.files).toEqual([ { path: '.env', display: '.env', added: 2, deleted: 0 }, { path: '.env.gone', display: '.env.gone', added: 1, deleted: 0 }, @@ -213,6 +215,8 @@ describe('workspace-changes in a repository', () => { await settle(ctx, session) await writeFile(join(cwd, 'x.txt'), 'x\n') toolCall(session, 1, 'bash', { command: 'x' }) + // A repository whose snapshot failed is not summarized from file-tool hunks as if it had no repository. + toolCall(session, 1, 'write', { file_path: 'x.txt', content: 'x\n' }, { meta: { operation: 'create', diffs: [] } }) endTurn(session, 1) await settle(ctx, session) expect(changes(ctx, session)).toEqual([]) @@ -221,6 +225,28 @@ describe('workspace-changes in a repository', () => { expect(own[0]).toContain('missing-git') }) + it('leaves nested repositories out even when a file tool edits inside them', async () => { + const cwd = await repository() + const sub = join(cwd, 'sub') + await mkdir(sub) + git(sub, 'init', '-q', '-b', 'main') + await writeFile(join(sub, 'inner.txt'), 'inner\n') + git(sub, 'add', '-A'); git(sub, 'commit', '-q', '-m', 'inner') + git(cwd, 'add', 'sub'); git(cwd, 'commit', '-q', '-m', 'gitlink') + const { ctx } = await boot() + const session = ctx.sessions.create(SessionId('gitlink'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + await writeFile(join(sub, 'inner.txt'), 'inner\nedited\n') + toolCall(session, 1, 'edit', { file_path: 'sub/inner.txt' }, { meta: { diffs: [{ path: 'sub/inner.txt', oldText: 'inner', newText: 'inner\nedited' }] } }) + await writeFile(join(cwd, 'top.txt'), 'top\n') + toolCall(session, 1, 'write', { file_path: 'top.txt', content: 'top\n' }, { meta: { operation: 'create', diffs: [] } }) + toolCall(session, 1, 'write', { file_path: 'same.txt', content: 'same\n' }, { meta: { operation: 'update', diffs: [] } }) + endTurn(session, 1) + await settle(ctx, session) + expect(changes(ctx, session).map(summary => summary.files.map(file => file.display))).toEqual([['top.txt']]) + }) + it('rejects non-positive bounds at load', async () => { const ctx = new Context() cleanups.push(() => ctx.fiber.dispose()) @@ -257,7 +283,7 @@ describe('workspace-changes without a repository', () => { endTurn(session, 1) await settle(ctx, session) expect(changes(ctx, session)).toEqual([{ - turn: 1, cwd, total: 2, + turn: 1, cwd, total: 2, added: 5, deleted: 1, files: [ { path: 'existing.txt', display: 'existing.txt', added: 3, deleted: 1 }, { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 2, deleted: 0 }, @@ -333,7 +359,7 @@ describe('workspace-changes without git', () => { endTurn(session, turn) await settle(ctx, session) } - expect(changes(ctx, session)).toEqual([{ turn: 2, cwd, total: 1, files: [{ path: 'w.txt', display: 'w.txt', added: 1, deleted: 0 }] }]) + expect(changes(ctx, session)).toEqual([{ turn: 2, cwd, total: 1, added: 1, deleted: 0, files: [{ path: 'w.txt', display: 'w.txt', added: 1, deleted: 0 }] }]) expect(info).toHaveBeenCalledTimes(1) expect(info.mock.calls[0]![0]).toContain('git is unavailable') }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 684e48ca58..a8b9e4fd66 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -6687,7 +6687,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WorkspaceChangesSummary', - declaration: 'export interface WorkspaceChangesSummary {\n turn: number;\n cwd: string;\n files: WorkspaceChangedFile[];\n total: number;\n}', + declaration: 'export interface WorkspaceChangesSummary {\n turn: number;\n cwd: string;\n files: WorkspaceChangedFile[];\n total: number;\n added: number;\n deleted: number;\n snapshot?: {\n before: string;\n after: string;\n };\n}', }, { name: 'WorkspaceCreateRequest', diff --git a/packages/fs/tool-fs/src/diff.ts b/packages/fs/tool-fs/src/diff.ts index 48766a9fbc..491d29c686 100644 --- a/packages/fs/tool-fs/src/diff.ts +++ b/packages/fs/tool-fs/src/diff.ts @@ -12,12 +12,14 @@ export const DIFF_CONTEXT = 3 /** * The `write`/`edit` tools' private `tool/result` `meta` payload: the applied - * contextual-diff hunks. Attached opaquely (as `unknown`) on the tool result and + * contextual-diff hunks, and for `write` whether the call created or updated + * the file, which tells an empty hunk list of a create from one of an + * unchanged overwrite. Attached opaquely (as `unknown`) on the tool result and * persisted with the session log — it must be JSON-serializable (the session * validates this at `append`), so `presentResult` reproduces the diff card on * replay. The producing tool owns and narrows this opaque shape. */ -export type FsDiffMeta = { diffs: FileDiff[] } +export type FsDiffMeta = { diffs: FileDiff[]; operation?: 'create' | 'update' } /** * Compute one {@link FileDiff} per hunk between `before` and `after`, each carrying the diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index f016741176..f51260d747 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -96,6 +96,7 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxController): void }, render: (_args, value) => [{ type: 'text', text: formatWriteOutput(value.path, value) }], presentationMeta: (args, value) => ({ + operation: value.operation, diffs: value.before === null ? [] : computeHunkDiffs(args.file_path, value.before, value.after) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 298bdd6a4d..4d18f0dcdf 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -640,7 +640,7 @@ describe('result-time contextual diff (meta + presentResult)', () => { await call(ctx, 'read', { file_path: 'a.txt' }, { session }) const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'a\nb\nc\nNEW\nd\ne\nf\n' }, { session }) expect(result.isError).toBe(false) - expect(result.meta).toEqual({ diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) + expect(result.meta).toEqual({ operation: 'update', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'x' }, result) expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) }) @@ -652,7 +652,7 @@ describe('result-time contextual diff (meta + presentResult)', () => { const session = { header: {} } const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session }) expect(result.isError).toBe(false) - expect(result.meta).toEqual({ diffs: [] }) + expect(result.meta).toEqual({ operation: 'create', diffs: [] }) const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result) expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] }) }) @@ -664,7 +664,8 @@ describe('result-time contextual diff (meta + presentResult)', () => { await call(ctx, 'read', { file_path: 'a.txt' }, { session }) const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session }) expect(result.isError).toBe(false) - expect(result.meta).toEqual({ diffs: [] }) + // The operation lets a consumer tell this unchanged overwrite from a create with the same empty hunk list. + expect(result.meta).toEqual({ operation: 'update', diffs: [] }) const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result) expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] }) }) diff --git a/snapshots/acp/fs-escalation-approved/session.v3.jsonl b/snapshots/acp/fs-escalation-approved/session.v3.jsonl index c38078c779..652d400ebd 100644 --- a/snapshots/acp/fs-escalation-approved/session.v3.jsonl +++ b/snapshots/acp/fs-escalation-approved/session.v3.jsonl @@ -16,7 +16,7 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} {"type":"approval/asked","data":{"id":"{{approval:1}}","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} {"type":"approval/decided","data":{"id":"{{approval:1}}","outcome":"allowed-once"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20},"stream":[{"type":"chunk","time":1788699392995,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699392995,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created"," successfully","."," The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," single"," word"," D","ONE","."]},{"type":"chunk","time":1788699392995,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699392995,"index":1,"dt":[0],"texts":["D","ONE"]},{"type":"chunk","time":1788699392995,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}},{"type":"chunk","time":1788699392995,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699392995,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}},{"type":"chunk","time":1788699392995,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/fs-delete-recreate/session.v3.jsonl b/snapshots/session/fs-delete-recreate/session.v3.jsonl index 842452547c..1b353bbc3c 100644 --- a/snapshots/session/fs-delete-recreate/session.v3.jsonl +++ b/snapshots/session/fs-delete-recreate/session.v3.jsonl @@ -29,7 +29,7 @@ {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"fs-delete-recreate","name":"write","arguments":"{\"file_path\":\"deleted.txt\",\"content\":\"fresh\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699359710,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699359710,"index":0,"dt":[],"id":"fs-delete-recreate","name":"write","args":["{\"file_path\":\"deleted.txt\",\"content\":\"fresh\\n\"}"]},{"type":"chunk","time":1788699359710,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-delete-recreate","name":"write","arguments":"{\"file_path\":\"deleted.txt\",\"content\":\"fresh\\n\"}"}}},{"type":"chunk","time":1788699359710,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699359710,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"fs-delete-recreate","name":"write","arguments":"{\"file_path\":\"deleted.txt\",\"content\":\"fresh\\n\"}"}} -{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"fs-delete-recreate"},"content":[{"type":"tool-result","toolCallId":"fs-delete-recreate","content":[{"type":"text","text":"{{cwd}}/deleted.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:11}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[29],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"fs-delete-recreate"},"content":[{"type":"tool-result","toolCallId":"fs-delete-recreate","content":[{"type":"text","text":"{{cwd}}/deleted.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:11}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[29],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1788699359733,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699359733,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699359733,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699359734,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1788699359734,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/fs-write-overwrite-bounded/session.v3.jsonl b/snapshots/session/fs-write-overwrite-bounded/session.v3.jsonl index f70bae76f8..53da1e92bd 100644 --- a/snapshots/session/fs-write-overwrite-bounded/session.v3.jsonl +++ b/snapshots/session/fs-write-overwrite-bounded/session.v3.jsonl @@ -19,7 +19,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"fs-bounded-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699370160,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699370160,"index":0,"dt":[],"id":"fs-bounded-write","name":"write","args":["{\"file_path\":\"data.txt\",\"content\":\"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"]},{"type":"chunk","time":1788699370160,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-bounded-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}}},{"type":"chunk","time":1788699370160,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699370160,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"fs-bounded-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"fs-bounded-write"},"content":[{"type":"tool-result","toolCallId":"fs-bounded-write","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"{{message:7}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"fs-bounded-write"},"content":[{"type":"tool-result","toolCallId":"fs-bounded-write","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"{{message:7}}"},"meta":{"operation":"update","diffs":[]}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1788699370184,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699370184,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699370184,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699370184,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1788699370184,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/fs-write-overwrite/session.v3.jsonl b/snapshots/session/fs-write-overwrite/session.v3.jsonl index 558e79ae87..d7b54fa835 100644 --- a/snapshots/session/fs-write-overwrite/session.v3.jsonl +++ b/snapshots/session/fs-write-overwrite/session.v3.jsonl @@ -19,7 +19,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"fs-overwrite-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"replaced\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699368911,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699368911,"index":0,"dt":[],"id":"fs-overwrite-write","name":"write","args":["{\"file_path\":\"data.txt\",\"content\":\"replaced\"}"]},{"type":"chunk","time":1788699368911,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"fs-overwrite-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"replaced\"}"}}},{"type":"chunk","time":1788699368911,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699368911,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"fs-overwrite-write","name":"write","arguments":"{\"file_path\":\"data.txt\",\"content\":\"replaced\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"fs-overwrite-write"},"content":[{"type":"tool-result","toolCallId":"fs-overwrite-write","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"{{message:7}}"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"fs-overwrite-write"},"content":[{"type":"tool-result","toolCallId":"fs-overwrite-write","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"{{message:7}}"},"meta":{"operation":"update","diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1788699368936,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699368936,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699368936,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699368936,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1788699368936,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/fs-write/session.v3.jsonl b/snapshots/session/fs-write/session.v3.jsonl index a91051e219..fcef2612e3 100644 --- a/snapshots/session/fs-write/session.v3.jsonl +++ b/snapshots/session/fs-write/session.v3.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Use the write tool (NOT","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30},"stream":[{"type":"chunk","time":1788699367607,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699367607,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," create"," a"," file"," named"," notes",".txt"," with"," the"," content"," \"","hello"," world","\""," using"," the"," write"," tool",","," then"," reply"," with"," \"","D","ONE","\"."]},{"type":"chunk","time":1788699367608,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699367608,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","args":["","{","\"","file","_path","\"",": ","\"","notes",".txt","\"",", ","\"","content","\"",": ","\"","hello"," world","\"","}"]},{"type":"chunk","time":1788699367608,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."}}},{"type":"chunk","time":1788699367608,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}},{"type":"chunk","time":1788699367608,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}}},{"type":"chunk","time":1788699367608,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file has been created. Now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":214,"outputTokens":20,"cacheReadTokens":2816,"reasoningTokens":17},"stream":[{"type":"chunk","time":1788699367638,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699367638,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," has"," been"," created","."," Now"," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]},{"type":"chunk","time":1788699367638,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699367638,"index":1,"dt":[0],"texts":["D","ONE"]},{"type":"chunk","time":1788699367638,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file has been created. Now I just need to reply with \"DONE\"."}}},{"type":"chunk","time":1788699367638,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699367638,"chunk":{"type":"usage","usage":{"inputTokens":214,"outputTokens":20,"cacheReadTokens":2816,"reasoningTokens":17}}},{"type":"chunk","time":1788699367638,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/session-sandbox-root/session.v3.jsonl b/snapshots/session/session-sandbox-root/session.v3.jsonl index 36577e199c..f8840c4854 100644 --- a/snapshots/session/session-sandbox-root/session.v3.jsonl +++ b/snapshots/session/session-sandbox-root/session.v3.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Use the write tool (NOT","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699426180,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699426180,"index":0,"dt":[],"id":"call_session_root","name":"write","args":["{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"]},{"type":"chunk","time":1788699426180,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}},{"type":"chunk","time":1788699426180,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699426180,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_session_root"},"content":[{"type":"tool-result","toolCallId":"call_session_root","content":[{"type":"text","text":"{{cwd}}/session-root.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_session_root"},"content":[{"type":"tool-result","toolCallId":"call_session_root","content":[{"type":"text","text":"{{cwd}}/session-root.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1788699426207,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699426207,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699426207,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699426207,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1788699426207,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/web/changed-files-turn/session.v3.jsonl b/snapshots/web/changed-files-turn/session.v3.jsonl index ca60e74614..958b906b4f 100644 --- a/snapshots/web/changed-files-turn/session.v3.jsonl +++ b/snapshots/web/changed-files-turn/session.v3.jsonl @@ -23,9 +23,9 @@ {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_z383sJwOMP6vmJp9Hag77052","name":"edit","arguments":"{\"file_path\": \"intro.md\", \"old_string\": \"# 示例项目\", \"new_string\": \"# 项目说明\"}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_z383sJwOMP6vmJp9Hag77052"},"content":[{"type":"tool-result","toolCallId":"call_00_z383sJwOMP6vmJp9Hag77052","content":[{"type":"text","text":"The file {{cwd}}/intro.md has been updated successfully."}],"isError":false}],"role":"user","id":"{{message:8}}"},"meta":{"diffs":[{"path":"intro.md","oldText":"# 示例项目\n\n一个用于演示的仓库。","newText":"# 项目说明\n\n一个用于演示的仓库。"}]}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_01_eusCmPFbiHy81iokiVY05329","name":"write","arguments":"{\"file_path\": \"src/util.ts\", \"content\": \"export function add(a: number, b: number): number {\\n return a + b;\\n}\\n\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_01_eusCmPFbiHy81iokiVY05329"},"content":[{"type":"tool-result","toolCallId":"call_01_eusCmPFbiHy81iokiVY05329","content":[{"type":"text","text":"{{cwd}}/src/util.ts\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:9}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_01_eusCmPFbiHy81iokiVY05329"},"content":[{"type":"tool-result","toolCallId":"call_01_eusCmPFbiHy81iokiVY05329","content":[{"type":"text","text":"{{cwd}}/src/util.ts\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:9}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_02_V7z2GSt4h4I0sCnJg4990423","name":"write","arguments":"{\"file_path\": \"app.local\", \"content\": \"mode=demo\\n\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_02_V7z2GSt4h4I0sCnJg4990423"},"content":[{"type":"tool-result","toolCallId":"call_02_V7z2GSt4h4I0sCnJg4990423","content":[{"type":"text","text":"{{cwd}}/app.local\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:10}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_02_V7z2GSt4h4I0sCnJg4990423"},"content":[{"type":"tool-result","toolCallId":"call_02_V7z2GSt4h4I0sCnJg4990423","content":[{"type":"text","text":"{{cwd}}/app.local\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:10}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_03_M8lD8W8TAtiIT141ej8n1520","name":"bash","arguments":"{\"command\": \"echo done >> notes.txt && cat notes.txt\", \"description\": \"Append done to notes.txt\"}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_03_M8lD8W8TAtiIT141ej8n1520"},"content":[{"type":"tool-result","toolCallId":"call_03_M8lD8W8TAtiIT141ej8n1520","content":[{"type":"text","text":"start\ndone\n"}],"isError":false}],"role":"user","id":"{{message:11}}"}},"sourceEventSeqs":[27],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} diff --git a/snapshots/web/permission-policy-context/session.v3.jsonl b/snapshots/web/permission-policy-context/session.v3.jsonl index 4a3bfcd770..0344ee4a9e 100644 --- a/snapshots/web/permission-policy-context/session.v3.jsonl +++ b/snapshots/web/permission-policy-context/session.v3.jsonl @@ -72,7 +72,7 @@ {"type":"tool/call","data":{"turn":4,"step":2,"callId":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace.\"}"}} {"type":"approval/asked","data":{"id":"{{approval:1}}","toolName":"write","callId":"call_00_NRXSzM55hPMs3seJDD8k7920","reason":"escalate sandbox to workspace-write: The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace."}} {"type":"approval/decided","data":{"id":"{{approval:1}}","outcome":"allowed-once"}} -{"type":"tool/result","data":{"turn":4,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_NRXSzM55hPMs3seJDD8k7920"},"content":[{"type":"tool-result","toolCallId":"call_00_NRXSzM55hPMs3seJDD8k7920","content":[{"type":"text","text":"{{cwd}}/policy-neutral.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:18}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":4,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_NRXSzM55hPMs3seJDD8k7920"},"content":[{"type":"tool-result","toolCallId":"call_00_NRXSzM55hPMs3seJDD8k7920","content":[{"type":"text","text":"{{cwd}}/policy-neutral.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:18}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[70],"surfaceOp":"append"} {"type":"step/end","data":{"turn":4,"step":2}} {"type":"step/start","data":{"turn":4,"step":3}} {"type":"assistant/message","data":{"turn":4,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created. Now I need to verify its contents as instructed."},{"type":"tool-call","id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","arguments":"{\"file_path\": \"policy-neutral.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":180,"outputTokens":61,"cacheReadTokens":15744,"reasoningTokens":15},"stream":[{"type":"chunk","time":1789457853866,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457853866,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created","."," Now"," I"," need"," to"," verify"," its"," contents"," as"," instructed","."]},{"type":"chunk","time":1789457853866,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457853866,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"","}"]},{"type":"chunk","time":1789457853866,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created. Now I need to verify its contents as instructed."}}},{"type":"chunk","time":1789457853866,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","arguments":"{\"file_path\": \"policy-neutral.txt\"}"}}},{"type":"chunk","time":1789457853866,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":61,"cacheReadTokens":15744,"reasoningTokens":15}}},{"type":"chunk","time":1789457853866,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/snapshots/web/present-svg/session.v3.jsonl b/snapshots/web/present-svg/session.v3.jsonl index 262511ea57..375c875b06 100644 --- a/snapshots/web/present-svg/session.v3.jsonl +++ b/snapshots/web/present-svg/session.v3.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"简单画一个 SVG 表示冯诺依曼","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."},{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"},{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55},"stream":[{"type":"chunk","time":1789457782571,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457782571,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," a"," simple"," SVG"," diagram"," of"," the"," von"," Neumann"," architecture",","," saved"," as"," von","-","neum","ann",".svg","."," Let"," me"," create"," a"," clean"," SVG"," showing"," CPU"," (","control"," unit",","," AL","U","),"," memory",","," input",","," and"," output"," with"," bidirectional"," data","/","control"," buses",".\n\n","Let"," me"," write"," the"," file","."]},{"type":"chunk","time":1789457782572,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789457782572,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0],"texts":["我来","创建一个","冯","诺","依","曼","架构","的"," SVG"," ","示意图","。"]},{"type":"chunk","time":1789457782572,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457782572,"index":2,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","args":["","{","\"","file","_path","\"",": ","\"","/","private","/","var","/f","old","ers","/n","5","/f","339","h","wh","15","_g","f","4","gk","8","ns","rt","9","qm","400","00","gn","/T","/d","sh","-web","-e","2","e","-w","s","-g","Ou","Z","JC","/","works","pace","/v","on","-","neum","ann",".svg","\"",", ","\"","content","\"",": ","\"","<","svg"," xmlns","=\\\"","http","://","www",".w","3",".org","/","200","0","/s","vg","\\\""," view","Box","=\\\"","0"," ","0"," ","900"," ","560","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," font","-family","=\\\"","Hel","vetica",","," A","rial",","," sans","-serif","\\\">\\n"," "," <","title",">","冯","诺","依","曼","架构","\\n\\n"," "," <","def","s",">\\n"," "," <","mark","er"," id","=\\\"","arrow","\\\""," marker","Width","=\\\"","10","\\\""," marker","Height","=\\\"","10","\\\""," ref","X","=\\\"","8","\\\""," ref","Y","=\\\"","3","\\\""," orient","=\\\"","auto","\\\">\\n"," "," <","path"," d","=\\\"","M","0",",","0"," L","8",",","3"," L","0",",","6"," Z","\\\""," fill","=\\\"#","555","\\\"/>\\n"," "," \\n"," "," \\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","0","\\\""," y","=\\\"","0","\\\""," width","=\\\"","900","\\\""," height","=\\\"","560","\\\""," fill","=\\\"#","f","af","bf","c","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","34","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","22","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","222","\\\">","冯","诺","依","曼","体系","结构"," (","Von"," Neumann"," Architecture",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","70","\\\""," width","=\\\"","300","\\\""," height","=\\\"","240","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","f","6","f","eb","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","98","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","f","6","f","eb","\\\">","中央","处理器"," CPU","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","325","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","d","df","4","ff","\\\""," stroke","=\\\"#","54","ae","ff","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","096","9","da","\\\">","控制","单元","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","0","b","539","4","\\\">","CU","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","指令","译","码"," ·"," ","时序","控制","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","455","\\\""," y","=\\\"","118","\\\""," width","=\\\"","120","\\\""," height","=\\\"","160","\\\""," rx","=\\\"","8","\\\""," fill","=\\\"#","fff","8","c","5","\\\""," stroke","=\\\"#","d","4","a","72","c","\\\""," stroke","-width","=\\\"","1",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","142","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12",".","5","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","9","a","670","0","\\\">","算术","逻辑","单元","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","158","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","7","a","520","0","\\\">","ALU","\\n"," "," <","text"," x","=\\\"","515","\\\""," y","=\\\"","262","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","算术","运算"," ·"," ","逻辑","运算","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","330","\\\""," y","=\\\"","186","\\\""," width","=\\\"","110","\\\""," height","=\\\"","56","\\\""," rx","=\\\"","6","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","2","\\\""," stroke","-d","ash","array","=\\\"","4",",","3","\\\"/>\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","208","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","寄存器","组","\\n"," "," <","text"," x","=\\\"","385","\\\""," y","=\\\"","224","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","Reg","isters"," /"," ","缓存","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","300","\\\""," y","=\\\"","400","\\\""," width","=\\\"","300","\\\""," height","=\\\"","110","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","428","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","17","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","cf","222","e","\\\">","内","存储器"," Memory","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","452","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," fill","=\\\"#","444","\\\">","存储","单元",":"," ","指令","和数据","\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","470","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","666","\\\">","(","存放","程序","与","数据",",","同一","总线","存取",")\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","40","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","输入","设备","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Input","\\n"," "," <","text"," x","=\\\"","125","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","键盘"," ·"," ","鼠标"," ·"," ","磁盘","\\n\\n"," "," \\n"," "," <","rect"," x","=\\\"","690","\\\""," y","=\\\"","150","\\\""," width","=\\\"","170","\\\""," height","=\\\"","120","\\\""," rx","=\\\"","10","\\\""," fill","=\\\"#","fff","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","2",".","5","\\\"/>\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","186","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","15","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","bf","398","9","\\\">","输出","设备","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","206","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","444","\\\">","Output","\\n"," "," <","text"," x","=\\\"","775","\\\""," y","=\\\"","238","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\">","显示器"," ·"," ","打印机","\\n\\n"," "," \\n"," "," \\n"," "," <","path"," d","=\\\"","M","450",",","320"," L","450",",","398","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","cf","222","e","\\\""," stroke","-width","=\\\"","3","\\\"/>\\n"," "," <","text"," x","=\\\"","420","\\\""," y","=\\\"","362","\\\""," text","-an","chor","=\\\"","end","\\\""," font","-size","=\\\"","11","\\\""," fill","=\\\"#","cf","222","e","\\\""," transform","=\\\"","rotate","(-","90"," ","420"," ","362",")","\\\">","地址","总线","\\n\\n"," "," \\n"," "," <","line"," x","1","=\\\"","300","\\\""," y","1","=\\\"","355","\\\""," x","2","=\\\"","600","\\\""," y","2","=\\\"","355","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","1","\\\""," stroke","-d","ash","array","=\\\"","2",",","3","\\\""," opacity","=\\\"","0",".","0","\\\"/>\\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","353","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","12","\\\""," font","-weight","=\\\"","bold","\\\""," fill","=\\\"#","555","\\\">","←"," ","数据","总线"," /"," ","控制","总线"," →","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","212",",","210"," L","298",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","1","a","7","f","37","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","212",",","196"," L","298",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","1","a","7","f","37","\\\">","数据","(","输入",")\\n"," "," <","text"," x","=\\\"","255","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","602",",","210"," L","688",",","210","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","bf","398","9","\\\""," stroke","-width","=\\\"","3","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","path"," d","=\\\"","M","602",",","196"," L","688",",","196","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","555","\\\""," stroke","-width","=\\\"","2","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","184","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10",".","5","\\\""," fill","=\\\"#","bf","398","9","\\\">","数据","(","输出",")\\n"," "," <","text"," x","=\\\"","645","\\\""," y","=\\\"","236","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","555","\\\">","控制","\\n\\n"," "," \\n"," "," <","path"," d","=\\\"","M","125",",","272"," L","125",",","300"," Q","125",",","340"," ","175",",","340"," L","298",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","160","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(-","8"," ","160"," ","328",")","\\\">","总线","\\n\\n"," "," <","path"," d","=\\\"","M","775",",","272"," L","775",",","300"," Q","775",",","340"," ","725",",","340"," L","602",",","340","\\\""," fill","=\\\"","none","\\\""," stroke","=\\\"#","8","c","959","f","\\\""," stroke","-width","=\\\"","1",".","8","\\\""," stroke","-d","ash","array","=\\\"","6",",","4","\\\""," marker","-end","=\\\"","url","(#","arrow",")","\\\"/>\\n"," "," <","text"," x","=\\\"","740","\\\""," y","=\\\"","328","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","10","\\\""," fill","=\\\"#","666","\\\""," transform","=\\\"","rotate","(","8"," ","740"," ","328",")","\\\">","总线","\\n\\n"," "," \\n"," "," <","text"," x","=\\\"","450","\\\""," y","=\\\"","530","\\\""," text","-an","chor","=\\\"","middle","\\\""," font","-size","=\\\"","11",".","5","\\\""," fill","=\\\"#","666","\\\">","核心","思想",":"," ","指令","与","数据","以","二进制","形式","统一","存放在","存储器","中"," ——"," “","存储","程序","”","\\n","\\n","\"","}"]},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.\n\nLet me write the file."}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"我来创建一个冯诺依曼架构的 SVG 示意图。"}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"usage","usage":{"inputTokens":7197,"outputTokens":2153,"totalTokens":10502,"cacheReadTokens":1152,"reasoningTokens":55}}},{"type":"chunk","time":1789457782585,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_yxav8LdZ9iwWpgkUnspF6361","name":"write","arguments":"{\"file_path\": \"{{cwd}}/von-neumann.svg\", \"content\": \"\\n 冯诺依曼架构\\n\\n \\n \\n \\n \\n \\n\\n \\n \\n 冯诺依曼体系结构 (Von Neumann Architecture)\\n\\n \\n \\n 中央处理器 CPU\\n\\n \\n \\n 控制单元\\n CU\\n 指令译码 · 时序控制\\n\\n \\n \\n 算术逻辑单元\\n ALU\\n 算术运算 · 逻辑运算\\n\\n \\n \\n 寄存器组\\n Registers / 缓存\\n\\n \\n \\n 内存储器 Memory\\n 存储单元: 指令和数据\\n (存放程序与数据,同一总线存取)\\n\\n \\n \\n 输入设备\\n Input\\n 键盘 · 鼠标 · 磁盘\\n\\n \\n \\n 输出设备\\n Output\\n 显示器 · 打印机\\n\\n \\n \\n \\n 地址总线\\n\\n \\n \\n ← 数据总线 / 控制总线 →\\n\\n \\n \\n \\n 数据(输入)\\n 控制\\n\\n \\n \\n \\n 数据(输出)\\n 控制\\n\\n \\n \\n 总线\\n\\n \\n 总线\\n\\n \\n 核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”\\n\\n\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_yxav8LdZ9iwWpgkUnspF6361"},"content":[{"type":"tool-result","toolCallId":"call_00_yxav8LdZ9iwWpgkUnspF6361","content":[{"type":"text","text":"{{cwd}}/von-neumann.svg\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_yxav8LdZ9iwWpgkUnspF6361"},"content":[{"type":"tool-result","toolCallId":"call_00_yxav8LdZ9iwWpgkUnspF6361","content":[{"type":"text","text":"{{cwd}}/von-neumann.svg\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."},{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14},"stream":[{"type":"chunk","time":1789457782632,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789457782632,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," is"," created","."," Let"," me"," present"," it"," as"," a"," deliver","able","."]},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789457782632,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","args":["","{","\"","files","\"",": ","[","{\"","path","\":"," \"","von","-","neum","ann",".svg","\","," \"","description","\":"," \"","冯","诺","依","曼","架构","示意图"," SVG","\"","}]","}"]},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file is created. Let me present it as a deliverable."}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_otuUentgajQb2RkFVoKT3699","name":"present","arguments":"{\"files\": [{\"path\": \"von-neumann.svg\", \"description\": \"冯诺依曼架构示意图 SVG\"}]}"}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"usage","usage":{"inputTokens":85,"outputTokens":80,"totalTokens":10661,"cacheReadTokens":10496,"reasoningTokens":14}}},{"type":"chunk","time":1789457782632,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} From a79b97ecca459afd9f60f9cb0e522e7bc39e37dd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 15 Sep 2026 18:38:33 +0800 Subject: [PATCH 24/37] feat(deliverables): compare each changed file from the card Rows of the changed-files card open a `changes-diff` right-Sidebar tab that compares the file at turn start and turn end. The workspace-changes recorder copies every file a file tool edits into the Session's temporary directory before the first edit and again at turn end, so files git does not cover are compared without git and counted first-to-last; `maxFileBytes` caps copies and snapshot reads, and `diffTimeoutMs` degrades a slow line comparison to whole-file replacement. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 8 +- .../2026-09-11-turn-changed-files-card.zh.md | 8 +- ...-09-15-changed-file-diff-preview.i18n.yaml | 6 + .../2026-09-15-changed-file-diff-preview.md | 43 +++ ...2026-09-15-changed-file-diff-preview.zh.md | 43 +++ apps/web/tests/changed-files-turn.e2e.ts | 28 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 2 +- docs/capability-seams.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 11 +- docs/config-catalog.zh.md | 11 +- docs/subsystems/deliverables.i18n.yaml | 4 +- docs/subsystems/deliverables.md | 85 +++++- docs/subsystems/deliverables.zh.md | 85 +++++- packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 17 +- packages/client/ui-deliverables/README.zh.md | 17 +- packages/client/ui-deliverables/package.json | 4 +- .../client/ui-deliverables/src/changes.ts | 92 ++++++- .../src/client/ChangedFiles.tsx | 42 ++- .../src/client/Deliverables.tsx | 11 +- .../src/client/DiffPreview.module.css | 20 ++ .../src/client/DiffPreview.tsx | 136 ++++++++++ .../src/client/changes-diff.ts | 70 +++++ .../src/client/diff-definition.ts | 34 +++ .../ui-deliverables/src/client/index.ts | 44 ++- .../ui-deliverables/src/client/locales.ts | 28 +- .../ui-deliverables/src/present-open.ts | 32 ++- .../tests/changes-open.host.spec.ts | 57 +++- .../tests/deliverables.client.spec.tsx | 79 ++++-- .../tests/diff-preview.client.spec.tsx | 218 +++++++++++++++ .../ui-deliverables/tsconfig.client.json | 3 + packages/deliverables/README.i18n.yaml | 4 +- packages/deliverables/README.md | 4 +- packages/deliverables/README.zh.md | 4 +- .../workspace-changes/README.i18n.yaml | 4 +- .../deliverables/workspace-changes/README.md | 19 +- .../workspace-changes/README.zh.md | 19 +- .../workspace-changes/src/capture.ts | 88 ++++++ .../workspace-changes/src/compare.ts | 66 +++++ .../deliverables/workspace-changes/src/git.ts | 47 +++- .../workspace-changes/src/index.ts | 49 +++- .../workspace-changes/src/numstat.ts | 129 +-------- .../workspace-changes/src/recorder.ts | 256 +++++++++++++----- .../workspace-changes/src/types.ts | 62 ++++- .../workspace-changes/tests/capture.spec.ts | 63 +++++ .../workspace-changes/tests/compare.spec.ts | 39 +++ .../workspace-changes/tests/git.spec.ts | 12 +- .../workspace-changes/tests/numstat.spec.ts | Bin 5538 -> 1507 bytes .../workspace-changes/tests/plugin.spec.ts | 175 +++++++++--- .../workspace-changes/tests/support.ts | 14 + .../extensions/tool-cordis/src/api-catalog.ts | 21 +- pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 2 +- scripts/type-equiv.manifest.json | 10 + .../web/changed-files-turn/ui.expected.md | 6 +- 62 files changed, 1935 insertions(+), 426 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md create mode 100644 .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md create mode 100644 packages/client/ui-deliverables/src/client/DiffPreview.module.css create mode 100644 packages/client/ui-deliverables/src/client/DiffPreview.tsx create mode 100644 packages/client/ui-deliverables/src/client/changes-diff.ts create mode 100644 packages/client/ui-deliverables/src/client/diff-definition.ts create mode 100644 packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx create mode 100644 packages/deliverables/workspace-changes/src/capture.ts create mode 100644 packages/deliverables/workspace-changes/src/compare.ts create mode 100644 packages/deliverables/workspace-changes/tests/capture.spec.ts create mode 100644 packages/deliverables/workspace-changes/tests/compare.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index 533905188a..3bedf1aad9 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: 37efc24b5eaa0a20af677a0e15977e3653e2fb66 -2026-09-11-turn-changed-files-card.zh.md: ae45644f90ab536d254ae83f68f4450edcd6b01e +2026-09-11-turn-changed-files-card.md: 3afae2c21ebba63c83f5cba5e2d1d01960f25684 +2026-09-11-turn-changed-files-card.zh.md: 782e8857834763ad4efbc6d79fb2832577b510ff diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index 37efc24b5e..3afae2c21e 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -16,13 +16,13 @@ The recorder snapshots the working tree with git at turn start and turn end: `ad Git is the default executable on `PATH`; no environment plugin is consulted. Outside any repository, or without git, no snapshot is taken and the summary lists the file-tool edits alone, with the working directory as the workspace, so the card still appears but misses shell edits. Nested repositories and submodules are gitlinks and are not descended into. -Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, or from the call's arguments when a result persists none, as a `write` that creates a file and every `str_replace_editor` mutation do; no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. +Changes outside snapshot coverage come from whole-file captures: before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the named file into the Session's temporary directory, once per path per turn, and copies it again at turn end; ignored files, files outside the work tree, and every file-tool edit without a snapshot are listed from a line comparison of the two copies ([comparison decision](2026-09-15-changed-file-diff-preview.md)). Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar. The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered. The Client keeps the latest announcement per turn and reads its summary once through the authenticated summary route. -The log deliberately carries nothing but the turn number. Summaries, snapshot trees, and the tree ids a later content comparison needs live only as long as the Session in the Host process: the summaries in the recorder, the objects in a temporary directory removed on disposal. A conversation reopened after a Host restart has no card for its earlier turns. The product decision is that a card whose content the Host can no longer open should not appear at all, so the card's lifetime equals the content's lifetime rather than the log's. Whether to keep content across restarts, and for how many turns, is left to #3822. +The log deliberately carries nothing but the turn number. Summaries, snapshot trees, and captured copies live only as long as the Session in the Host process: the summaries in the recorder, the objects and copies in a temporary directory removed on disposal. A conversation reopened after a Host restart has no card for its earlier turns. The product decision is that a card whose content the Host can no longer open should not appear at all, so the card's lifetime equals the content's lifetime rather than the log's. Content is not kept across restarts ([comparison decision](2026-09-15-changed-file-diff-preview.md)). ## Alternatives considered @@ -34,7 +34,7 @@ The log deliberately carries nothing but the turn number. Summaries, snapshot tr **A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; until git exists the card lists file-tool edits only. -**Recording the pre-edit file content at first touch** would make hunk counts exact and enable full-file diffs for uncovered files, but requires the file tools to hand their pre-read content to the recorder; the persisted hunks already carry the before and after text of each edit for that purpose. +**Summing the hunks the file tools persist** for uncovered files was the first implementation; it counted a repeatedly edited line more than once and could not show a whole-file comparison, so the recorder now copies the whole file at first touch instead ([comparison decision](2026-09-15-changed-file-diff-preview.md)). **Recording the file list and counts in the event** was the first implementation: the card would then render from the log forever, while the content it opens would not survive. It was replaced by the announcement-only event so that the card and its content share one lifetime. @@ -46,7 +46,7 @@ The log deliberately carries nothing but the turn number. Summaries, snapshot tr ## Consequences -Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the Session's temporary directory, which disposal removes. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. +Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the Session's temporary directory, which disposal removes. Edits the user makes during a turn are attributed to it. Every file-tool edit also copies its whole file into that directory once per turn. The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card whenever a turn changes a file; one dedicated scenario seeds a git repository so the card also carries a shell edit, while the others list their file-tool writes alone. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index ae45644f90..782e885783 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -16,13 +16,13 @@ Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-chan git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。不在任何仓库内或没有 git 时,不做快照,摘要只列文件工具的编辑,并以工作目录作为工作区,因此卡片仍会出现,但缺少 shell 的改动。嵌套仓库和 submodule 是 gitlink,不会深入。 -快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,结果没有持久化 hunk 时取调用参数,新建文件的 `write` 和 `str_replace_editor` 的每种修改都是这种情况;不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 +快照覆盖之外的改动来自整文件捕获:在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把所指文件复制到 Session 的临时目录,每轮每个路径一次,轮次结束时再复制一次;被忽略的文件、工作树之外的文件,以及没有快照时的每一次文件工具编辑,都由两份副本的逐行对比列出([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。 记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。 -日志有意只携带轮号。摘要、快照树以及将来做内容对比要用的 tree id 只在 Host 进程内随 Session 存活:摘要在记录器里,对象在释放时删除的临时目录里。Host 重启后重新打开的对话,先前轮次没有卡片。产品上的决定是,Host 已经打不开内容的卡片根本不该出现,所以卡片的寿命等于内容的寿命,而不是日志的寿命。是否跨重启保留内容、保留多少轮,留给 #3822。 +日志有意只携带轮号。摘要、快照树与捕获的副本只在 Host 进程内随 Session 存活:摘要在记录器里,对象和副本在释放时删除的临时目录里。Host 重启后重新打开的对话,先前轮次没有卡片。产品上的决定是,Host 已经打不开内容的卡片根本不该出现,所以卡片的寿命等于内容的寿命,而不是日志的寿命。内容不跨重启保留([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。 ## Alternatives considered @@ -34,7 +34,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 **为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片只列文件工具的编辑。 -**在首次触碰时记录编辑前的文件内容**能让 hunk 计数精确并为未覆盖文件提供整文件 diff,但要求文件工具把预先读取的内容交给记录器;持久化的 hunk 已经为此带上了每次编辑的前后文本。 +**累加文件工具持久化的 hunk** 是未覆盖文件的第一版实现;它会把反复编辑的行计算多次,也给不出整文件对比,因此记录器改为在首次触碰时复制整个文件([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。 **把文件列表和行数记进事件**是第一版实现:卡片从此可以永远从日志渲染,而它打开的内容却活不了那么久。它被只做宣告的事件取代,让卡片和内容共用一个寿命。 @@ -46,7 +46,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 ## Consequences -每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入 Session 的临时目录,释放时一并删除。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 +每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入 Session 的临时目录,释放时一并删除。用户在轮次进行中的编辑会被算到该轮。每次文件工具编辑还会把整个文件复制进该目录,每轮一次。 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只要某一轮改动了文件就会新增该事件与卡片;一个专门的场景种入 git 仓库,让卡片也带上 shell 的改动,其余场景只列它们的文件工具写入。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml new file mode 100644 index 0000000000..fc147110a3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.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-09-15-changed-file-diff-preview.md +2026-09-15-changed-file-diff-preview.md: df01bc2a7f3b8def92d5da0cee8bc3110a073abd +2026-09-15-changed-file-diff-preview.zh.md: cc5d9379121de15f37b72823e37d243b6a6a8bfb diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md new file mode 100644 index 0000000000..df01bc2a7f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md @@ -0,0 +1,43 @@ +# Agent Note: Changed-file comparison tab + +Status: implemented + +English | [中文](2026-09-15-changed-file-diff-preview.zh.md) + +## Problem + +The [changed-files card](2026-09-11-turn-changed-files-card.md) tells the user which files a turn changed and by how many lines, but not what changed. A row opened the file's current content in the Sidebar, or in a desktop application, which shows neither the turn's edit nor the state before it. Files git does not cover — ignored files, files outside the repository, and every file-tool edit in a working directory without a repository — had only the hunks the file tools persist with their results: partial context, counts summed over repeated edits, and no whole-file comparison at all. + +## Decision + +Each card row opens a `changes-diff` tab in the right Sidebar that compares the file at turn start and turn end. The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) recorder serves the comparison through `workspaceChanges.diff(sessionId, seq, index, signal)`; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) registers the tab type, addresses it by the viewed Session, the announcing event's sequence, and the file's index, and reads the comparison through an authenticated route. Rows open the comparison with and without a Host desktop; the tab's header offers the default-application open when a desktop exists, so the earlier row-level native open moves there and the card keeps one behavior. + +Files git does not cover are compared the way Codex's turn diff tracker compares its `apply_patch` edits: from whole-file copies, not from hunks. Before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder's `tools/pre-execute` step, which already waits for the baseline snapshot, copies the named file into the Session's temporary directory beside the snapshot objects, once per path per turn; at turn end it copies the path again. The copies are named by the SHA-1 of their bytes, so identical content is stored once, and they need no git. Paths the snapshots cover keep their git counts; every other captured path is listed from a line comparison of its two copies, which counts a repeatedly edited line once and includes a shell edit made after the file-tool edit. The persisted hunks and the argument-derived hunks are no longer read by the recorder. + +Two bounds keep the copies and the comparisons small. `maxFileBytes` caps a copy and a snapshot blob read for a comparison; a larger file is listed with `oversized`, without counts, and its comparison is refused, which is what makes whole-file copies affordable. `diffTimeoutMs` bounds the line comparison, the same 100 ms Codex uses; past it the comparison degrades to one hunk that replaces every line, marked `coarse`, so a pathological file never stalls the turn's record or the tab. Both are Config fields. + +The comparison is computed when asked for, on the Host, from the two content sources kept beside the served summary: a path in a snapshot tree, read with `ls-tree -l` and `cat-file blob` under the byte cap, or a copy read from disk. Snapshot sides git reported as binary and copies holding a NUL byte serve no lines. The tab renders the hunks with old and new line numbers and no syntax highlighting; a comparison the Host no longer serves, a failed read, a binary file, and an oversized file each show one line. + +Content still lives only as long as the Session in this Host process, as the card decision settled; the comparison shares the card's lifetime, so a conversation reopened after a Host restart has neither. + +## Alternatives considered + +**Whole-file before and after text in the file tools' result metadata**, as Codex's `apply_patch` returns, would put every edited file twice into the Session log. The recorder captures the file itself instead, so the log keeps carrying only the turn number. + +**Storing the copies in memory**, as Codex does for its per-turn tracker, grows with every turn because the records live for the Session; the copies go to the Session's temporary directory beside the snapshot objects and are read only when a comparison is asked for. + +**Storing the copies as git blobs in the private object store** would have shared one read path with the snapshots but made the file-tool path depend on git, which the working directories without a repository or without git cannot provide; plain files under the same directory keep that path git-free. + +**A shadow repository for working directories outside any repository** remains deferred; the copies cover the file-tool edits there, which is the part a user can act on. + +**Syntax highlighting and a side-by-side view** in the tab are deferred until the plain unified view proves insufficient. + +**Keeping rows opening the current file** would have left the comparison one click further away; the current file still opens from the prose links and the Files tab. + +## Consequences + +Every file-tool edit reads and writes its whole file once per turn on the Host, bounded by `maxFileBytes`, even for paths the snapshots also cover; a Session's temporary directory holds the copies until disposal. Comparison line counts for uncovered files are first-to-last, not sums. A comparison costs at most two `ls-tree` and two `cat-file` commands plus one bounded line comparison. + +The `WorkspaceChangedFile` type gains `oversized`, `WorkspaceChanges` gains `diff`, and two served types, `WorkspaceDiffHunk` and `WorkspaceFileDiff`, join the subsystem page. The Session log is unchanged. The changed-files card's rows are labeled as opening the file's changes in both languages, so the recorded Web scenario's golden changed; the same scenario now opens a shell-appended file's comparison from the snapshots and an ignored file's from its copies. + +Focused tests cover whole-file capture classification, the timeout degradation, comparisons served from snapshot trees, renames, deleted files, oversized blobs, and copies, the comparison route, the tab's addresses, store, and states, and the card's row wiring. diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md new file mode 100644 index 0000000000..cc5d937912 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 改动文件对比 tab + +Status: implemented + +[English](2026-09-15-changed-file-diff-preview.md) | 中文 + +## Problem + +[改动文件卡片](2026-09-11-turn-changed-files-card.zh.md)告诉用户一轮改了哪些文件、改了多少行,但没说改了什么。点一行打开的是文件当前内容,在 Sidebar 里或在桌面应用里,既看不到本轮的编辑,也看不到编辑前的状态。git 覆盖不到的文件——被忽略的文件、仓库之外的文件,以及没有仓库的工作目录里的每一次文件工具编辑——只有文件工具随结果持久化的 hunk:上下文不完整,行数按重复编辑累加,完全没有整文件对比。 + +## Decision + +卡片的每一行在右侧 Sidebar 打开一个 `changes-diff` tab,对比该文件在轮次开始与结束时的内容。Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 记录器通过 `workspaceChanges.diff(sessionId, seq, index, signal)` 提供对比;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)注册该 tab 类型,用当前查看的 Session、宣告事件的序号和文件下标给它定址,并通过经过认证的路由读取对比。有没有 Host 桌面,行都打开对比;有桌面时 tab 头部提供用默认应用打开,先前行级别的原生打开移到那里,卡片只保留一种行为。 + +git 覆盖不到的文件按 Codex 的 turn diff tracker 对比其 `apply_patch` 编辑的方式对比:用整文件副本,不用 hunk。在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器本来就要等待基线快照的 `tools/pre-execute` 步骤把所指文件复制到 Session 临时目录里快照对象旁边,每轮每个路径一次;轮次结束时再复制一次该路径。副本按其字节的 SHA-1 命名,相同内容只存一份,且不需要 git。快照覆盖到的路径保留 git 的行数;其余每个被捕获的路径由两份副本的逐行对比列出,反复编辑的行只计一次,文件工具编辑之后的 shell 改动也包含在内。记录器不再读取持久化的 hunk 和由参数推出的 hunk。 + +两个上限让副本和对比保持小。`maxFileBytes` 限制副本和为对比而读出的快照 blob;更大的文件列出时带 `oversized`,没有行数,其对比被拒绝,整文件副本因此负担得起。`diffTimeoutMs` 限制逐行对比,与 Codex 一样是 100 毫秒;超时后退化为一个替换全部行的 hunk,标记 `coarse`,因此病态的文件从不会拖住本轮记录或 tab。两者都是 Config 字段。 + +对比在被请求时在 Host 上计算,来源是保存在所提供摘要旁边的两侧内容来源:快照树中的路径,用 `ls-tree -l` 和 `cat-file blob` 在字节上限之内读出;或者从磁盘读取的副本。git 报告为二进制的快照一侧和含 NUL 字节的副本不提供行。tab 渲染带旧新行号的 hunk,没有语法高亮;Host 已不再提供的对比、读取失败、二进制文件和过大的文件各显示一行。 + +内容仍然只在本 Host 进程内随 Session 存活,这是卡片决定已经定下的;对比与卡片同寿命,因此 Host 重启后重新打开的对话两者都没有。 + +## Alternatives considered + +**把整文件的前后文本放进文件工具的结果元数据**,像 Codex 的 `apply_patch` 返回的那样,会把每个被编辑的文件两次写进 Session 日志。记录器改为自己捕获文件,日志继续只携带轮号。 + +**把副本存在内存里**,像 Codex 的每轮 tracker 那样,会随每一轮增长,因为记录要活到 Session 结束;副本放到 Session 临时目录里快照对象旁边,只在请求对比时读取。 + +**把副本存为私有对象库里的 git blob** 能和快照共用一条读取路径,但会让文件工具这条路径依赖 git,而没有仓库或没有 git 的工作目录给不了它;同一目录下的普通文件让这条路径与 git 无关。 + +**给仓库之外的工作目录建影子仓库**仍然推迟;副本已经覆盖那里的文件工具编辑,这是用户能采取行动的部分。 + +**tab 里的语法高亮和左右对照视图**推迟到纯 unified 视图被证明不够用时再做。 + +**保留行打开当前文件**会让对比多一次点击才能到达;当前文件仍可从正文链接和文件 tab 打开。 + +## Consequences + +每次文件工具编辑在 Host 上每轮读写一次整个文件,受 `maxFileBytes` 限制,即使快照也覆盖该路径;Session 的临时目录保存副本直到释放。未覆盖文件的对比行数是首尾对比,不是累加。一次对比最多花费两次 `ls-tree`、两次 `cat-file` 和一次有界的逐行对比。 + +`WorkspaceChangedFile` 类型新增 `oversized`,`WorkspaceChanges` 新增 `diff`,两个提供给客户端的类型 `WorkspaceDiffHunk` 和 `WorkspaceFileDiff` 加入子系统页面。Session 日志不变。改动文件卡片的行在两种语言里都标为打开该文件的改动,因此录制的 Web 场景的黄金文件变了;同一场景现在会从快照打开一个 shell 追加过的文件的对比,从副本打开一个被忽略文件的对比。 + +聚焦测试覆盖整文件捕获的分类、超时退化、从快照树提供的对比、改名、删除的文件、过大的 blob 和副本、对比路由、tab 的地址、store 与各状态,以及卡片行的接线。 diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 90d636dcfd..411b250ecb 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -1,4 +1,4 @@ -/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card. */ +/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card; its rows open comparisons. */ import { execFileSync } from 'node:child_process' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -110,9 +110,31 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1) expect(await card.getByRole('listitem').count()).toBe(3) expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1) - // Without a Host desktop the header is a label and rows preview in the Sidebar. + // Without a Host desktop the header is a label; every row opens its comparison in the Sidebar. expect(await card.getByRole('button', { name: '打开改动文件所在的文件夹' }).count()).toBe(0) - expect(await card.getByRole('button', { name: '在侧边栏打开 notes.txt' }).count()).toBe(1) + expect(await card.getByRole('button', { name: '查看 notes.txt 的改动' }).count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('opens a shell-appended file’s comparison from the snapshots and an ignored file’s from its captured copies', async () => { + const card = page.locator('[data-changed-files]') + const column = page.locator('[data-rightbar-col]') + await card.getByRole('button', { name: '查看 notes.txt 的改动' }).click() + const notes = column.locator('[data-changes-diff][data-diff-state="text"]') + await notes.waitFor({ state: 'visible' }) + expect(await column.locator('[data-dockkit-tab]').filter({ hasText: 'notes.txt' }).count()).toBe(1) + expect(await notes.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`))).toEqual([ + 'context:11 start', 'add:2+done', + ]) + // The ignored file has no snapshot; its comparison comes from the copies captured around the write call. + await card.getByRole('button', { name: '查看 app.local 的改动' }).click() + const local = column.locator('[data-changes-diff][data-diff-state="text"]').filter({ hasText: 'mode=demo' }) + await local.waitFor({ state: 'visible' }) + expect(await local.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`))).toEqual(['add:1+mode=demo']) + expect(await local.getByText('本轮新建的文件').count()).toBe(1) + // No desktop, so the header offers no native open. + expect(await local.getByRole('button').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 5f2eac556f..97b66223ae 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 9dad96da1399441f96355a13d3848fcf01a5c177 -capability-seams.zh.md: 470694f746e500330ee3acf56ed1fa912c9c0088 +capability-seams.md: cf5a3211cffd2cd066fb25a7aecb3f2275f3f9b4 +capability-seams.zh.md: 87a80c9824e1978a6e1b40f509b1736a2c0a131a diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9dad96da13..cf5a3211cf 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -544,7 +544,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. | -| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn. | +| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced and each listed file's turn-start and turn-end comparison, by Session and event sequence, until that Session is disposed; the log carries only the turn. | | `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 470694f746..87a80c9824 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -546,7 +546,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 | -| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn. | +| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced and each listed file's turn-start and turn-end comparison, by Session and event sequence, until that Session is disposed; the log carries only the turn. | | `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | 通过子进程提供方与类型化 Remote 传输管理用户终端进程、解析默认 shell,并恢复有界终端屏幕。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3401fcce7d..aa734175fc 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 54fc0bd7e93f2b613d4c90c1bdffc3457f9db05c -config-catalog.zh.md: c037ad8908bea443234639c1ce2e4484844e24f3 +config-catalog.md: ce41d91277040f8c51eb27d63379368695bae8fd +config-catalog.zh.md: 64dd37959e2304f395a1d6e8c22345453d71c66c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 54fc0bd7e9..ce41d91277 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3638,7 +3638,7 @@ Source: [`packages/workflow/workflow-ptc/src/index.ts:32`](../packages/workflow/ Requires: `subprocess` ```ts config-catalog -/** Snapshot bounds. Invalid values fail plugin load. */ +/** Snapshot, capture, and comparison bounds. Invalid values fail plugin load. */ export interface Config { /** Milliseconds one git command may run before the turn's record is abandoned. */ timeoutMs: number @@ -3646,10 +3646,17 @@ export interface Config { outputMaxBytes: number /** Maximum files carried by one summary; `total` still reports the complete count. */ maxFiles: number + /** + * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; + * a larger file is listed without counts or comparison. + */ + maxFileBytes: number + /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */ + diffTimeoutMs: number } ``` -Source: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts) +Source: [`packages/deliverables/workspace-changes/src/index.ts:33`](../packages/deliverables/workspace-changes/src/index.ts) ## Loadable plugins with no config diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c037ad8908..64dd37959e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3640,7 +3640,7 @@ export interface Config { 依赖: `subprocess` ```ts config-catalog -/** Snapshot bounds. Invalid values fail plugin load. */ +/** Snapshot, capture, and comparison bounds. Invalid values fail plugin load. */ export interface Config { /** Milliseconds one git command may run before the turn's record is abandoned. */ timeoutMs: number @@ -3648,10 +3648,17 @@ export interface Config { outputMaxBytes: number /** Maximum files carried by one summary; `total` still reports the complete count. */ maxFiles: number + /** + * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; + * a larger file is listed without counts or comparison. + */ + maxFileBytes: number + /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */ + diffTimeoutMs: number } ``` -来源: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts) +来源: [`packages/deliverables/workspace-changes/src/index.ts:36`](../packages/deliverables/workspace-changes/src/index.ts) ## 无配置的可加载插件 diff --git a/docs/subsystems/deliverables.i18n.yaml b/docs/subsystems/deliverables.i18n.yaml index f5d6f11736..f7397bf69f 100644 --- a/docs/subsystems/deliverables.i18n.yaml +++ b/docs/subsystems/deliverables.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/subsystems/deliverables.md -deliverables.md: cf795d747576451aafb104aad930efc352000fd3 -deliverables.zh.md: e4bb8bb2e590982d714701f9ab0b1851732831d2 +deliverables.md: 3347481f3e69d9a3921bb8784335b62bc9d869c2 +deliverables.zh.md: d3d10565d58664d749cb0c9dbacf1fa2ab741066 diff --git a/docs/subsystems/deliverables.md b/docs/subsystems/deliverables.md index cf795d7475..3347481f3e 100644 --- a/docs/subsystems/deliverables.md +++ b/docs/subsystems/deliverables.md @@ -2,7 +2,7 @@ English | [中文](deliverables.zh.md) -What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end (or, outside a repository, from the file tools' persisted hunks alone), announced by a log-only event and served by a Host service while the Session lives. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md). +What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end plus whole-file captures around each file-tool edit for the paths git does not cover, announced by a log-only event and served by a Host service while the Session lives together with each listed file's turn-start and turn-end comparison. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md). Sources: [`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts), [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) @@ -21,7 +21,7 @@ interface PresentedFile { ## `WorkspaceChangedFile` — one changed file ```ts type-equiv -/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */ +/** One file changed during a turn, with line counts from git or from the whole-file captures around its file-tool edits. */ interface WorkspaceChangedFile { /** Path relative to the Session working directory, or an absolute Host path outside it. */ path: string @@ -31,12 +31,14 @@ interface WorkspaceChangedFile { * directory, otherwise the absolute path. Always slash-separated. */ display: string - /** Lines added; zero for a binary file. */ + /** Lines added; zero for a binary or oversized file. */ added: number - /** Lines deleted; zero for a binary file. */ + /** Lines deleted; zero for a binary or oversized file. */ deleted: number - /** Present when git reported the file as binary. */ + /** Present when git reported the file as binary, or when a captured side holds a NUL byte. */ binary?: true + /** Present when a captured side exceeded the plugin's `maxFileBytes`; the file is listed without counts or comparison. */ + oversized?: true } ``` @@ -62,10 +64,54 @@ interface WorkspaceChangesSummary { } ``` -## `WorkspaceChanges` — the Host service serving summaries +## `WorkspaceDiffHunk` — one unified-diff hunk ```ts type-equiv -/** Serves the summaries the recorder keeps for live Sessions. */ +/** One unified-diff hunk with three context lines; every line keeps its `+`, `-`, or space prefix. */ +interface WorkspaceDiffHunk { + /** First line of the hunk in the turn-start content, 1-based; a side without lines starts at 1 with zero lines. */ + oldStart: number + /** Lines of the hunk taken from the turn-start content. */ + oldLines: number + /** First line of the hunk in the turn-end content, 1-based; a side without lines starts at 1 with zero lines. */ + newStart: number + /** Lines of the hunk taken from the turn-end content. */ + newLines: number + /** Hunk body in order, each line prefixed with `+`, `-`, or a space. */ + lines: string[] +} +``` + +## `WorkspaceFileDiff` — one file's comparison + +```ts type-equiv +/** The comparison of one listed file's turn-start and turn-end contents, computed when asked for. */ +type WorkspaceFileDiff = + | { + kind: 'text' + /** The listed file's `path`. */ + path: string + /** The listed file's `display`. */ + display: string + /** Whether the file existed at turn start. */ + before: boolean + /** Whether the file existed at turn end. */ + after: boolean + /** Hunks in file order; empty when both sides hold the same lines. */ + hunks: WorkspaceDiffHunk[] + /** True when the line comparison exceeded the plugin's `diffTimeoutMs` and every line is shown as replaced. */ + coarse: boolean + } + /** A side git reported as binary or that holds a NUL byte; no lines are served. */ + | { kind: 'binary'; path: string; display: string } + /** A side larger than the plugin's `maxFileBytes`; no lines are served. */ + | { kind: 'oversized'; path: string; display: string } +``` + +## `WorkspaceChanges` — the Host service serving summaries and comparisons + +```ts type-equiv +/** Serves the summaries and file comparisons the recorder keeps for live Sessions. */ interface WorkspaceChanges { /** * The summary announced by one `workspace/changes` event. @@ -74,12 +120,22 @@ interface WorkspaceChanges { * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. */ summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined + /** + * Compare one listed file's contents at turn start and turn end. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @param index - the file's index in the summary's `files`. + * @param signal - cancels the reads. + * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index. + * @throws when a snapshot read fails for a live Session. + */ + diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise } ``` ## Durable events and the served summary -`tool-present` declaration-merges `deliverables/presented: { turn; callId; files: PresentedFile[] }` into `SessionEventMap`, appended once per successful final `present` result. `workspace-changes` merges `workspace/changes: { turn }`, appended when a top-level turn stops; the summary that event announced is not in the log but is returned by `workspaceChanges.summary(sessionId, seq)` for the event's sequence until the Session is disposed, so a conversation reopened after a Host restart has no changed-files card for its earlier turns. A later event for the same turn replaces the earlier one, so a client keeps only the latest. The generated [persistence catalog](../persistence-catalog.md#deliverablespresented--log-only) records both declaration sites. Neither event reaches the model. +`tool-present` declaration-merges `deliverables/presented: { turn; callId; files: PresentedFile[] }` into `SessionEventMap`, appended once per successful final `present` result. `workspace-changes` merges `workspace/changes: { turn }`, appended when a top-level turn stops; the summary that event announced is not in the log but is returned by `workspaceChanges.summary(sessionId, seq)` for the event's sequence until the Session is disposed, so a conversation reopened after a Host restart has no changed-files card for its earlier turns. `workspaceChanges.diff(sessionId, seq, index, signal)` compares one listed file on the same terms. A later event for the same turn replaces the earlier one, so a client keeps only the latest. The generated [persistence catalog](../persistence-catalog.md#deliverablespresented--log-only) records both declaration sites. Neither event reaches the model. @@ -93,7 +149,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.workspaceChanges` — `WorkspaceChanges` -Serves the summaries the recorder keeps for live Sessions. +Serves the summaries and file comparisons the recorder keeps for live Sessions. ```ts cordis-catalog /** @@ -103,6 +159,17 @@ Serves the summaries the recorder keeps for live Sessions. * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. */ summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined + +/** + * Compare one listed file's contents at turn start and turn end. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @param index - the file's index in the summary's `files`. + * @param signal - cancels the reads. + * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index. + * @throws when a snapshot read fails for a live Session. + */ +diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise ``` Types: [SessionId](core.md) diff --git a/docs/subsystems/deliverables.zh.md b/docs/subsystems/deliverables.zh.md index e4bb8bb2e5..d3d10565d5 100644 --- a/docs/subsystems/deliverables.zh.md +++ b/docs/subsystems/deliverables.zh.md @@ -2,7 +2,7 @@ [English](deliverables.md) | 中文 -记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出(不在仓库内时只由文件工具持久化的 hunk 得出),用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。 +记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出,git 覆盖不到的路径则由文件工具每次编辑前后的整文件捕获得出,用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务连同每个所列文件在轮开始与轮结束时的对比一起提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。 源码:[`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts)、[`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts) @@ -21,7 +21,7 @@ interface PresentedFile { ## `WorkspaceChangedFile`:一个改动的文件 ```ts type-equiv -/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */ +/** One file changed during a turn, with line counts from git or from the whole-file captures around its file-tool edits. */ interface WorkspaceChangedFile { /** Path relative to the Session working directory, or an absolute Host path outside it. */ path: string @@ -31,12 +31,14 @@ interface WorkspaceChangedFile { * directory, otherwise the absolute path. Always slash-separated. */ display: string - /** Lines added; zero for a binary file. */ + /** Lines added; zero for a binary or oversized file. */ added: number - /** Lines deleted; zero for a binary file. */ + /** Lines deleted; zero for a binary or oversized file. */ deleted: number - /** Present when git reported the file as binary. */ + /** Present when git reported the file as binary, or when a captured side holds a NUL byte. */ binary?: true + /** Present when a captured side exceeded the plugin's `maxFileBytes`; the file is listed without counts or comparison. */ + oversized?: true } ``` @@ -62,10 +64,54 @@ interface WorkspaceChangesSummary { } ``` -## `WorkspaceChanges`:提供摘要的 Host 服务 +## `WorkspaceDiffHunk`:一个 unified diff hunk ```ts type-equiv -/** Serves the summaries the recorder keeps for live Sessions. */ +/** One unified-diff hunk with three context lines; every line keeps its `+`, `-`, or space prefix. */ +interface WorkspaceDiffHunk { + /** First line of the hunk in the turn-start content, 1-based; a side without lines starts at 1 with zero lines. */ + oldStart: number + /** Lines of the hunk taken from the turn-start content. */ + oldLines: number + /** First line of the hunk in the turn-end content, 1-based; a side without lines starts at 1 with zero lines. */ + newStart: number + /** Lines of the hunk taken from the turn-end content. */ + newLines: number + /** Hunk body in order, each line prefixed with `+`, `-`, or a space. */ + lines: string[] +} +``` + +## `WorkspaceFileDiff`:一个文件的对比 + +```ts type-equiv +/** The comparison of one listed file's turn-start and turn-end contents, computed when asked for. */ +type WorkspaceFileDiff = + | { + kind: 'text' + /** The listed file's `path`. */ + path: string + /** The listed file's `display`. */ + display: string + /** Whether the file existed at turn start. */ + before: boolean + /** Whether the file existed at turn end. */ + after: boolean + /** Hunks in file order; empty when both sides hold the same lines. */ + hunks: WorkspaceDiffHunk[] + /** True when the line comparison exceeded the plugin's `diffTimeoutMs` and every line is shown as replaced. */ + coarse: boolean + } + /** A side git reported as binary or that holds a NUL byte; no lines are served. */ + | { kind: 'binary'; path: string; display: string } + /** A side larger than the plugin's `maxFileBytes`; no lines are served. */ + | { kind: 'oversized'; path: string; display: string } +``` + +## `WorkspaceChanges`:提供摘要与对比的 Host 服务 + +```ts type-equiv +/** Serves the summaries and file comparisons the recorder keeps for live Sessions. */ interface WorkspaceChanges { /** * The summary announced by one `workspace/changes` event. @@ -74,12 +120,22 @@ interface WorkspaceChanges { * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. */ summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined + /** + * Compare one listed file's contents at turn start and turn end. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @param index - the file's index in the summary's `files`. + * @param signal - cancels the reads. + * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index. + * @throws when a snapshot read fails for a live Session. + */ + diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise } ``` ## 持久事件与提供的摘要 -`tool-present` 通过声明合并把 `deliverables/presented: { turn; callId; files: PresentedFile[] }` 加入 `SessionEventMap`,每次 `present` 的最终结果成功时追加一条。`workspace-changes` 合并 `workspace/changes: { turn }`,在顶层轮停止时追加;该事件宣告的摘要不在日志里,而是由 `workspaceChanges.summary(sessionId, seq)` 按事件序号返回,直到 Session 释放,因此 Host 重启后重新打开的对话,先前轮次没有改动文件卡片。同一轮后来的事件替代先前的,客户端只保留最新一条。生成的[持久化目录](../persistence-catalog.zh.md#deliverablespresented--log-only)记录了两处声明位置。两个事件都不会进入模型请求。 +`tool-present` 通过声明合并把 `deliverables/presented: { turn; callId; files: PresentedFile[] }` 加入 `SessionEventMap`,每次 `present` 的最终结果成功时追加一条。`workspace-changes` 合并 `workspace/changes: { turn }`,在顶层轮停止时追加;该事件宣告的摘要不在日志里,而是由 `workspaceChanges.summary(sessionId, seq)` 按事件序号返回,直到 Session 释放,因此 Host 重启后重新打开的对话,先前轮次没有改动文件卡片。`workspaceChanges.diff(sessionId, seq, index, signal)` 按同样的条件对比一个所列文件。同一轮后来的事件替代先前的,客户端只保留最新一条。生成的[持久化目录](../persistence-catalog.zh.md#deliverablespresented--log-only)记录了两处声明位置。两个事件都不会进入模型请求。 @@ -93,7 +149,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.workspaceChanges` — `WorkspaceChanges` -Serves the summaries the recorder keeps for live Sessions. +Serves the summaries and file comparisons the recorder keeps for live Sessions. ```ts cordis-catalog /** @@ -103,6 +159,17 @@ Serves the summaries the recorder keeps for live Sessions. * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. */ summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined + +/** + * Compare one listed file's contents at turn start and turn end. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @param index - the file's index in the summary's `files`. + * @param signal - cancels the reads. + * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index. + * @throws when a snapshot read fails for a live Session. + */ +diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise ``` Types: [SessionId](core.zh.md) diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 7013ef504d..2d68f819b3 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: b1e01080de00410e39db83d13664fb707f0a7863 -README.zh.md: 1b605a6b87ada47f5adc99525e66892a4d583314 +README.md: c2dd351b3d15d55484456779aa069d4759f9ff13 +README.zh.md: 4016ec4ba2a204747b614d43135acc3fdb0ee841 diff --git a/packages/client/README.md b/packages/client/README.md index b1e01080de..c2dd351b3d 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -73,7 +73,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — | | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | -| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card, delivery cards, and clickable final-response file references | — | +| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card with its comparison tabs, delivery cards, and clickable final-response file references | — | | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 1b605a6b87..4016ec4ba2 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -73,7 +73,7 @@ kind: "package-group" | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — | | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — | -| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片、交付文件卡片与可点击的最终响应文件引用 | — | +| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片及其对比 tab、交付文件卡片与可点击的最终响应文件引用 | — | | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 156fdfeaca..0197f44322 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/ui-deliverables/README.md -README.md: 4547d0887b069ed40686af8821214d96b28f6c00 -README.zh.md: 8d3534b9fc2b72d3308b10b62f7536a20e8c90f1 +README.md: f0a82e44441e9cd8a0a0f259af9e903ad9eee693 +README.zh.md: fc689b3c7b969b8ef76098c79de602d076a4218e diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 4547d0887b..f0a82e4444 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -1,5 +1,5 @@ --- -description: "Changed files, deliveries, and clickable file references for the Web GUI: the changed-files card and delivery cards a finished turn ends with, and inline-code links in the closing prose; for users and maintainers of the deliverables experience." +description: "Changed files, deliveries, and clickable file references for the Web GUI: the changed-files card and delivery cards a finished turn ends with, the comparison tab each changed file opens, and inline-code links in the closing prose; for users and maintainers of the deliverables experience." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders the changed-files card a finished turn ends with — the files the turn changed, with the line counts the Host recorded from git snapshots — plus cards for explicitly delivered files, and links matching inline-code references in the closing prose so a mentioned file opens in the right Sidebar. Listed and linked paths come from the recorded summary, successful mutations, and explicit deliveries, never from the prose. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, cards, and prose links together. +This package renders the changed-files card a finished turn ends with — the files the turn changed, with the Host's line counts, each opening a Sidebar tab comparing the file at turn start and turn end — plus cards for explicitly delivered files, and links matching inline-code references in the closing prose so a mentioned file opens in the right Sidebar. Listed and linked paths come from the recorded summary, successful mutations, and explicit deliveries, never from the prose. Only the shipped Web patch loads this package; removing its cordis.yml entry removes the guidance, cards, and prose links together. ## Table of Contents @@ -36,7 +36,11 @@ The `present` tool row shows running, delivered, failed, or interrupted status; ### The changed-files card -The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, or “binary” for a binary file. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager and each row opens its file in the default application, both through authenticated Host actions that show the delivery cards' pending and retryable-error states in place of the counts; a completed open shows the counts again. Without a desktop the header is a plain label and rows preview their files in the right Sidebar. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. +The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens its file's comparison in the right Sidebar. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager through an authenticated Host action that shows the delivery cards' pending and retryable-error states in place of the totals; a completed open shows the totals again. Without a desktop the header is a plain label. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. + +### The comparison tab + +A row opens a `changes-diff` tab addressed by the viewed Session, the announcing event's sequence, and the file's index, titled with the file name; opening the same row again reveals the existing tab. The tab reads the comparison once through the authenticated comparison route and shows the file's path in its header. A text comparison lists its hunks with the old and new line numbers of every line, additions and deletions in the success and error colours, and a note when the file was created or deleted in the turn, when both sides hold the same lines, or when the Host's line comparison timed out and every line shows as replaced. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. With a Host desktop available the header offers opening the file in the default application, with the same pending and retryable-error states as the cards. The comparison is the turn's snapshot of the file, not its current content; the current content opens from the prose links or the Files tab. ### Inline-code links @@ -50,9 +54,9 @@ The closing prose links produced or delivered paths: an inline-code token resolv
      Implementation internals — click to expand -The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. +The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-diff/session////` through `ctx.sidebarRight.openResource`; the package registers the `changes-diff` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat, which reads the comparison through the authenticated comparison route into a store cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory, and the comparison tab's native open uses the same indexed coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
      @@ -99,7 +103,8 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. -- **Folder opening needs a Host desktop** — without one the card header is inert and rows fall back to the right Sidebar's text preview, which shows files only. +- **Folder and native opening need a Host desktop** — without one the card header is inert and the comparison tab offers no native open; the comparison itself needs only the Host that recorded the turn. +- **Comparisons are unhighlighted unified diffs** — the tab shows plain hunks without syntax colouring or a side-by-side view. - **Package-local header glyph** — the card's angle-bracket mark lives in `src/client/icons.tsx` until the shared icon set carries it; its props already match the shared icon contract. - **Files outside the workspace open by absolute path only** — the recorded path is the Host path at recording time; a moved workspace or a different viewing Session cannot relocate it. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 8d3534b9fc..fc689b3c7b 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的改动文件、交付文件与可点击文件引用:已完成轮次末尾的改动文件卡片与交付文件卡片,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。" +description: "Web GUI 的改动文件、交付文件与可点击文件引用:已完成轮次末尾的改动文件卡片与交付文件卡片、每个改动文件打开的对比 tab,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包渲染已完成轮次末尾的改动文件卡片——列出本轮改动的文件及 Host 从 git 快照记录的增删行数——以及显式交付文件的卡片,并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。列出与链接的路径来自记录的改动摘要、成功的文件修改与显式交付,而非收尾正文——无论模型是否记得点名,改动文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、卡片与正文链接。 +本包渲染已完成轮次末尾的改动文件卡片——列出本轮改动的文件及 Host 记录的行数,每一行打开 Sidebar 中对比该文件在轮次开始与结束时内容的 tab——以及显式交付文件的卡片,并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。列出与链接的路径来自记录的改动摘要、成功的文件修改与显式交付,而非收尾正文。只有正式提供的 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、卡片与正文链接。 ## 目录 @@ -36,7 +36,11 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 ### 改动文件卡片 -卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。Host 有桌面时,标题在文件管理器中打开包含所列文件的最深工作区文件夹,每一行用默认应用打开该文件,两者都通过经过认证的 Host 操作完成,并在计数位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示计数。没有桌面时标题是普通标签,行改为在右侧 Sidebar 中预览文件。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 +卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开该文件的对比。Host 有桌面时,标题通过经过认证的 Host 操作在文件管理器中打开包含所列文件的最深工作区文件夹,并在合计位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示合计。没有桌面时标题是普通标签。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 + +### 对比 tab + +行打开一个 `changes-diff` tab,其地址由当前查看的 Session、宣告事件的序号和文件下标组成,以文件名作标题;再次点击同一行会显示已有的 tab。tab 通过经过认证的对比路由读取一次对比,并在头部显示文件路径。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,新增与删除分别用成功色和错误色,并在文件是本轮新建或删除、两侧内容相同,或 Host 的逐行对比超时而按整文件替换显示时给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。Host 有桌面时,头部提供用默认应用打开该文件,等待与可重试错误状态与卡片一致。对比是本轮对该文件的快照,不是它当前的内容;当前内容可从正文链接或文件 tab 打开。 ### 行内代码链接 @@ -50,9 +54,9 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
      实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 +Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-diff/session////`;本包在 `builtin` 档为该模式注册 `changes-diff` tab 类型,并把其 body 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的对比路由把对比读进一个连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录;对比 tab 的原生打开使用同一组带下标的坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
      @@ -99,7 +103,8 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 -- **打开文件夹需要 Host 桌面**——没有桌面时卡片标题不可点击,行退回到右侧 Sidebar 的文本预览,该预览仅支持文件。 +- **打开文件夹和原生打开需要 Host 桌面**——没有桌面时卡片标题不可点击,对比 tab 也不提供原生打开;对比本身只需要记录了该轮的 Host。 +- **对比是没有高亮的 unified diff**——tab 只显示纯文本 hunk,没有语法着色,也没有左右对照视图。 - **包内的标题字形**——卡片的尖括号标记放在 `src/client/icons.tsx` 中,直到共享图标集收录它;其 props 已与共享图标契约一致。 - **工作区外的文件只按绝对路径打开**——记录的路径是记录时的 Host 路径;工作区移动或换一个查看 Session 都无法重新定位它。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 5d05c8345f..c9a1cfdb00 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", - "description": "Changed-files card, delivery cards, and clickable final-response file references for Web", + "description": "Changed-files card with per-file comparison tabs, delivery cards, and clickable final-response file references for Web", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-tool" ], "platform": "web" @@ -56,6 +57,7 @@ "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts index bf42a100b1..b6773dcebd 100644 --- a/packages/client/ui-deliverables/src/changes.ts +++ b/packages/client/ui-deliverables/src/changes.ts @@ -1,16 +1,34 @@ -/** Validate workspace-change records that cross the Host route and address their summary and native-open actions. */ +/** Validate workspace-change records that cross the Host routes and address their summary, comparison, and native-open actions. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { WorkspaceChangedFile, WorkspaceChangesSummary } from '@deepseek-ai/dsh-workspace-changes/types' +import type { WorkspaceChangedFile, WorkspaceChangesSummary, WorkspaceDiffHunk, WorkspaceFileDiff } from '@deepseek-ai/dsh-workspace-changes/types' /** Authenticated GET route serving one announced change summary while its Session lives. */ export const CHANGED_FILES_PATH = '/api/changes.summary' +/** Authenticated GET route serving one listed file's turn-start and turn-end comparison while its Session lives. */ +export const CHANGES_DIFF_PATH = '/api/changes.diff' + /** Authenticated POST route for opening a changed file, or the changed files' common folder, on the Host desktop. */ export const CHANGES_OPEN_PATH = '/api/changes.open' +/** Resource-address prefix of a changed file's comparison tab in the right Sidebar. */ +export const CHANGES_DIFF_ADDRESS = 'dsh-resource://changes-diff/session/' + /** The summary fields the route serves; the Host keeps the working directory and snapshot ids to itself. */ export type ChangesSummary = Pick +/** The comparison the route serves, as the Host computed it. */ +export type ChangesDiff = WorkspaceFileDiff + +/** Coordinates of one listed file's comparison: the viewed Session, the announcing event, and the file's index. */ +export interface ChangesDiffCoordinates { + sessionId: SessionId + seq: number + index: number + /** The listed file's display path, carried for the tab title. */ + display: string +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -22,9 +40,10 @@ function isRecord(value: unknown): value is Record { */ export function isChangedFile(value: unknown): value is WorkspaceChangedFile { if (!isRecord(value)) return false - const { path, display, added, deleted, binary } = value + const { path, display, added, deleted, binary, oversized } = value return typeof path === 'string' && path.length > 0 && typeof display === 'string' && display.length > 0 - && Number.isSafeInteger(added) && Number.isSafeInteger(deleted) && (binary === undefined || binary === true) + && Number.isSafeInteger(added) && Number.isSafeInteger(deleted) + && (binary === undefined || binary === true) && (oversized === undefined || oversized === true) } /** @@ -40,6 +59,29 @@ export function isChangesSummary(value: unknown): value is ChangesSummary { && Array.isArray(files) && files.every(isChangedFile) } +function isHunk(value: unknown): value is WorkspaceDiffHunk { + if (!isRecord(value)) return false + const { oldStart, oldLines, newStart, newLines, lines } = value + return [oldStart, oldLines, newStart, newLines].every(field => Number.isSafeInteger(field) && (field as number) >= 0) + && Array.isArray(lines) && lines.every(line => typeof line === 'string' && /^[+ -]/.test(line)) +} + +/** + * Validate a comparison read from the comparison route. + * @param value - decoded JSON. + * @returns whether the value is a text comparison with well-formed hunks, or a binary or oversized refusal. + */ +export function isChangesDiff(value: unknown): value is ChangesDiff { + if (!isRecord(value)) return false + const { kind, path, display } = value + if (typeof path !== 'string' || path.length === 0 || typeof display !== 'string' || display.length === 0) return false + if (kind === 'binary' || kind === 'oversized') return true + if (kind !== 'text') return false + const { before, after, hunks, coarse } = value + return typeof before === 'boolean' && typeof after === 'boolean' && typeof coarse === 'boolean' + && Array.isArray(hunks) && hunks.every(isHunk) +} + /** * Validate the `workspace/changes` event data read from a Session log. * @param value - decoded durable event data. @@ -59,6 +101,17 @@ export function changesSummaryUrl(sessionId: SessionId, seq: number): string { return `${CHANGED_FILES_PATH}?${new URLSearchParams({ sessionId, seq: String(seq) })}` } +/** + * Build authenticated coordinates for one listed file's comparison. + * @param sessionId - owning Session. + * @param seq - workspace/changes event sequence. + * @param index - original index in the summary's files array. + * @returns same-origin comparison URL. + */ +export function changesDiffUrl(sessionId: SessionId, seq: number, index: number): string { + return `${CHANGES_DIFF_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` +} + /** * Build authenticated coordinates for a changed file or the changed files' common folder. * @param sessionId - owning Session. @@ -71,3 +124,34 @@ export function changedFileUrl(sessionId: SessionId, seq: number, index: number if (index !== null) query.set('index', String(index)) return `${CHANGES_OPEN_PATH}?${query}` } + +/** + * The right-Sidebar address of one listed file's comparison. The display path + * rides the address only for the tab title; the coordinates identify the content. + * @param coordinates - viewed Session, announcing event, file index, and display path. + * @returns a `dsh-resource://changes-diff/session/…` address. + */ +export function changesDiffAddress({ sessionId, seq, index, display }: ChangesDiffCoordinates): string { + return `${CHANGES_DIFF_ADDRESS}${encodeURIComponent(sessionId)}/${seq}/${index}/${encodeURIComponent(display)}` +} + +/** + * Read the coordinates back out of a comparison address. + * @param address - a resource address. + * @returns the coordinates, or undefined for any other address. + */ +export function parseChangesDiffAddress(address: string): ChangesDiffCoordinates | undefined { + if (!address.startsWith(CHANGES_DIFF_ADDRESS)) return undefined + const parts = address.slice(CHANGES_DIFF_ADDRESS.length).split('/') + if (parts.length !== 4) return undefined + const [sessionId, seq, index, display] = parts as [string, string, string, string] + if (sessionId === '' || display === '' || !/^\d+$/.test(seq) || !/^\d+$/.test(index)) return undefined + try { + return { + sessionId: decodeURIComponent(sessionId) as SessionId, seq: Number(seq), index: Number(index), display: decodeURIComponent(display), + } + } catch { + // A malformed percent sequence is not an address this package minted. + return undefined + } +} diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index add98fde32..1eb2d9bf47 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,4 +1,4 @@ -/** The changed-files card: a folder-opening header, per-file line counts, and a three-row fold. */ +/** The changed-files card: a folder-opening header, per-file line counts opening each file's comparison, and a three-row fold. */ import { useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -17,9 +17,9 @@ const COLLAPSED_ROWS = 3 const GROUPED = new Intl.NumberFormat('en-US') /** - * Gesture state worth showing in place of the counts: pending, or failed. A - * completed open shows the counts again. The card never reveals, so only open - * phases occur. + * Folder gesture state worth showing in place of the totals: pending, or + * failed. A completed open shows the totals again. The card never reveals, so + * only open phases occur. */ function gesture(phase: PresentedOpenPhase | undefined): { key: 'presented.opening' | 'presented.error' | 'presented.nativeUnavailable'; failed: boolean } | undefined { switch (phase) { @@ -39,13 +39,13 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop } /** - * Render one turn's changed files. Rows open files in the Host's default - * application when a desktop is available and otherwise preview them in the - * right Sidebar; the header opens the files' common folder only with a desktop. - * @param props - the recorded summary, Host capabilities, gesture status, openers, and localized copy. + * Render one turn's changed files. Each row opens its file's turn-start and + * turn-end comparison in the right Sidebar; the header opens the files' common + * folder only with a Host desktop. + * @param props - the recorded summary, Host capabilities, folder gesture status, openers, and localized copy. * @returns the card. */ -export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openFile, t }: { +export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openDiff, t }: { /** The served summary with the sequence of the event that announced it. */ changes: Pick & { seq: number } cwd: string | undefined @@ -53,7 +53,8 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op host: PresentedHost | null phases: Record onOpen: (index: number | null) => void - openFile: (path: string) => void + /** Open the comparison of the file at an original summary index. */ + openDiff: (index: number, display: string) => void } & PropsLocale) { const [expanded, setExpanded] = useState(false) const native = host !== null && host.available @@ -77,25 +78,20 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op disabled={folder !== undefined && !folder.failed} onClick={() => { onOpen(null) }}>{summary} :
      {summary}
      }
        - {rows.map((file, index) => { - const phase = phases[changedFileUrl(sessionId, changes.seq, index)] - const status = gesture(phase) - // A file without a verified Host path falls back to the Sidebar preview the status names. - const opensNatively = native && phase !== 'nativeUnavailable' - return
      • + {rows.map((file, index) => ( +
      • - })} + ))}
      {foldable && } +
  • + {(state === undefined || state === 'loading') &&

    {t('diff.loading')}

    } + {state === 'missing' &&

    {t('diff.missing')}

    } + {state === 'error' &&
    + {t('diff.error')} + +
    } + {typeof state === 'object' && state.kind === 'binary' &&

    {t('diff.binary')}

    } + {typeof state === 'object' && state.kind === 'oversized' &&

    {t('diff.oversized')}

    } + {typeof state === 'object' && state.kind === 'text' && } +
    + ) +} + +/** The hunks of a text comparison with their line numbers. */ +function TextDiff({ diff, t }: { diff: Extract } & PropsLocale): ReactNode { + const note = noteOf(diff) + return ( +
    + {note !== undefined &&

    {t(note)}

    } + {diff.coarse &&

    {t('diff.coarse')}

    } + {diff.hunks.map((hunk, position) => ( +
    +
    {`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`}
    + {hunkRows(hunk).map((row, at) => ( +
    + {row.old ?? ''} + {row.new ?? ''} + {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} + {row.text} +
    + ))} +
    + ))} +
    + ) +} diff --git a/packages/client/ui-deliverables/src/client/changes-diff.ts b/packages/client/ui-deliverables/src/client/changes-diff.ts new file mode 100644 index 0000000000..c9fb23b0fd --- /dev/null +++ b/packages/client/ui-deliverables/src/client/changes-diff.ts @@ -0,0 +1,70 @@ +/** Cache of the file comparisons the Host serves for listed changed files, read once per comparison and again on request. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { changesDiffUrl, isChangesDiff, type ChangesDiff } from '../changes.ts' + +/** + * A comparison, `'missing'` once the Host no longer serves it, `'error'` for + * a failed read a later request retries, or `'loading'` while the request runs. + */ +export type ChangesDiffState = ChangesDiff | 'missing' | 'error' | 'loading' + +/** One browser plugin's comparison reads, cleared on connection replacement and cancelled on disposal. */ +export class ChangesDiffStore { + /** Comparison URLs key the state across Sessions, turns, and files. */ + readonly state = createSnapshotStore>({}) + private readonly lifetime = new AbortController() + /** The connection generation the current states belong to; a reset aborts it so no older read publishes. */ + private generation = new AbortController() + private readonly pending = new Set>() + + /** + * Read one comparison; a cached comparison or a missing one is kept, a failed one is read again. + * @param sessionId - viewed Session. + * @param seq - the announcing event's sequence. + * @param index - the file's index in the summary. + * @returns after the state is published. + */ + async load(sessionId: SessionId, seq: number, index: number): Promise { + const url = changesDiffUrl(sessionId, seq, index) + const current = this.state.getSnapshot()[url] + if (this.lifetime.signal.aborted || (current !== undefined && current !== 'error')) return + this.state.update((state) => { state[url] = 'loading' }) + const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal])) + this.pending.add(task) + try { + await task + } finally { + this.pending.delete(task) + } + } + + /** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */ + reset(): void { + this.generation.abort() + this.generation = new AbortController() + this.state.set({}) + } + + /** Cancel outstanding reads and wait until none can publish state. */ + async dispose(): Promise { + this.lifetime.abort() + await Promise.all(this.pending) + } + + private async read(url: string, signal: AbortSignal): Promise { + let next: ChangesDiffState = 'error' + try { + const response = await fetch(url, { signal }) + if (response.status === 404) next = 'missing' + else if (response.ok) { + const value: unknown = await response.json() + if (isChangesDiff(value)) next = value + } + } catch { + // A transport failure stays retryable until the connection is replaced and the store reset. + next = 'error' + } + if (!signal.aborted) this.state.update((state) => { state[url] = next }) + } +} diff --git a/packages/client/ui-deliverables/src/client/diff-definition.ts b/packages/client/ui-deliverables/src/client/diff-definition.ts new file mode 100644 index 0000000000..97eab9ce9f --- /dev/null +++ b/packages/client/ui-deliverables/src/client/diff-definition.ts @@ -0,0 +1,34 @@ +/** + * What the `changes-diff` tab type IS: the right-Sidebar viewer of one listed + * changed file's turn-start and turn-end comparison. It claims the + * `dsh-resource://changes-diff/session/…` addresses the changed-files card + * mints; the coordinates in the address identify the content, and the display + * path they carry names the tab. + */ +import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { parseChangesDiffAddress } from '../changes.ts' +import { basename } from '../presented.ts' + +/** The tab kind this package owns. */ +export const CHANGES_DIFF_KIND = 'changes-diff' + +/** This implementation's identity in the tab system, and the key its body registers under. */ +export const CHANGES_DIFF_ID = '@deepseek-ai/dsh-client-ui-deliverables' + +/** + * The comparison type's registry definition. + * @returns the definition to register. + */ +export function changesDiffDefinition(): SidebarRightTabDefinition { + return { + id: CHANGES_DIFF_ID, + kind: CHANGES_DIFF_KIND, + patterns: ['dsh-resource://changes-diff/**'], + priority: 'builtin', + canOpen: address => parseChangesDiffAddress(address) !== undefined, + title: (address) => { + const display = parseChangesDiffAddress(address)?.display + return display === undefined ? address : basename(display) + }, + } +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 3f97942fb4..c7a3121bb1 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -1,11 +1,12 @@ /** * Deliverables plugin, browser half: registers the changed-files card and - * delivery cards into the chat view's turn-tail chain, and provides the - * `chatFileMentions` service that links inline-code mentions of produced or - * delivered files in the closing prose. All policy lives here — the supported - * mutation calls, mention matching, row cap, and copy — so composing this - * plugin out of cordis.yml removes both surfaces entirely; the owning view - * renders an empty chain and inert prose at zero cost. + * delivery cards into the chat view's turn-tail chain, the `changes-diff` + * right-Sidebar tab type that shows one listed file's turn-start and turn-end + * comparison, and provides the `chatFileMentions` service that links + * inline-code mentions of produced or delivered files in the closing prose. + * All policy lives here — the supported mutation calls, mention matching, row + * cap, and copy — so composing this plugin out of cordis.yml removes every + * surface; the owning view renders an empty chain and inert prose at zero cost. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' @@ -14,10 +15,15 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { changesDiffAddress } from '../changes.ts' +import { ChangesDiffStore } from './changes-diff.ts' import { ChangesSummaryStore } from './changes-summary.ts' import { PresentedOpenController } from './present-open.ts' import { PresentRow } from './PresentRow.tsx' import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx' +import { DiffPreview, type DiffPreviewInjected } from './DiffPreview.tsx' +import { CHANGES_DIFF_ID, changesDiffDefinition } from './diff-definition.ts' import { en, NS, zh, type DeliverablesKey } from './locales.ts' import { deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles, @@ -25,25 +31,27 @@ import { declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Changed-files card, delivery card, and file-mention copy. */ + /** Changed-files card, comparison tab, delivery card, and file-mention copy. */ 'deliverables': DeliverablesKey } } -/** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session'] +/** Required services for the tail-slot and tab-type registrations and their dictionaries. */ +export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight'] /** - * Client plugin body: register the dictionaries and the turn-tail entry. + * Client plugin body: register the dictionaries, the turn-tail entry, and the comparison tab type. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() const summaries = new ChangesSummaryStore() - ctx.effect(() => () => Promise.all([opener.dispose(), summaries.dispose()])) + const diffs = new ChangesDiffStore() + ctx.effect(() => () => Promise.all([opener.dispose(), summaries.dispose(), diffs.dispose()])) ctx.on('connection/reset', () => { opener.resetHost() summaries.reset() + diffs.reset() }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') @@ -59,12 +67,26 @@ export function apply(ctx: ClientContext): void { loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), + openChangesDiff: (coordinates) => { ctx.sidebarRight.openResource(changesDiffAddress(coordinates)) }, }), }, Deliverables), ) ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( { name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow, )) + ctx.effect(() => ctx.sidebarRightTabs.register(changesDiffDefinition()), 'ui-deliverables: changes-diff type') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( + { + name: 'sidebar.right.pane.tab', key: CHANGES_DIFF_ID, locale: NS, + inject: (): DiffPreviewInjected => ({ + hooks: { changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host }, + loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index), + reloadPresentedHost: () => opener.loadHost(), + openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), + }), + }, + DiffPreview, + )), 'ui-deliverables: changes-diff body') // The prose side of the same vocabulary: the chat view reaches this face // via ctx.get, so its absence — this plugin composed out — is the off state. const t = ctx.locale.bind(NS) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index c9695cffd9..2852518f5a 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -1,4 +1,4 @@ -/** `deliverables` namespace dictionaries. */ +/** `deliverables` namespace dictionaries: cards, comparison tab, and file-mention copy. */ /** Dictionary namespace owned by this plugin. */ export const NS = 'deliverables' @@ -50,6 +50,19 @@ export const zh = { 'changes.collapseAria': '收起改动文件列表', 'changes.folderOpening': '正在打开文件夹…', 'changes.folderError': '无法打开文件夹,点击重试', + 'changes.oversized': '过大', + 'changes.viewDiff': '查看 {name} 的改动', + 'diff.loading': '正在读取改动…', + 'diff.missing': '这轮改动的内容已不可用', + 'diff.error': '无法读取改动', + 'diff.binary': '二进制文件,无法显示改动', + 'diff.oversized': '文件过大,无法显示改动', + 'diff.created': '本轮新建的文件', + 'diff.deleted': '本轮删除的文件', + 'diff.unchanged': '两侧内容相同', + 'diff.coarse': '逐行对比超时,按整个文件替换显示', + 'diff.openNative': '用默认应用打开', + 'diff.openNativeError': '打开失败,点击重试', } /** English dictionary (same key set). */ @@ -99,6 +112,19 @@ export const en: Record = { 'changes.collapseAria': 'Collapse changed files', 'changes.folderOpening': 'Opening folder…', 'changes.folderError': 'Could not open folder. Click to retry.', + 'changes.oversized': 'too large', + 'changes.viewDiff': 'View changes to {name}', + 'diff.loading': 'Reading changes…', + 'diff.missing': 'The contents of this turn’s changes are no longer available', + 'diff.error': 'Could not read the changes', + 'diff.binary': 'Binary file; changes cannot be shown', + 'diff.oversized': 'File too large; changes cannot be shown', + 'diff.created': 'Created in this turn', + 'diff.deleted': 'Deleted in this turn', + 'diff.unchanged': 'Both sides hold the same lines', + 'diff.coarse': 'Line comparison timed out; shown as a whole-file replacement', + 'diff.openNative': 'Open in default app', + 'diff.openNativeError': 'Could not open. Click to retry.', } /** Union of this namespace's dictionary keys. */ diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 193694b3eb..067a1b26b6 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -1,4 +1,4 @@ -/** Serve change summaries and open declared or changed workspace paths verified by the viewed Session's filesystem. */ +/** Serve change summaries and comparisons, and open declared or changed workspace paths verified by the viewed Session's filesystem. */ import { dirname, isAbsolute, relative, resolve } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller' @@ -10,13 +10,13 @@ import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' -import { CHANGES_OPEN_PATH, CHANGED_FILES_PATH, type ChangesSummary } from './changes.ts' +import { CHANGES_DIFF_PATH, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, type ChangesSummary } from './changes.ts' import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts' /** * Register the deliverables routes inside Connection's authentication fence: - * desktop metadata, change summaries, declared-file actions, and changed-file - * or folder opening. + * desktop metadata, change summaries and comparisons, declared-file actions, + * and changed-file or folder opening. * @param ctx - Session lookup, change summaries, native opener, and route lifetime. */ export function registerPresentOpen(ctx: Context): void { @@ -35,10 +35,13 @@ export function registerPresentOpen(ctx: Context): void { lifetime.abort() await Promise.allSettled(pending) }) - for (const [path, handler] of [[PRESENT_OPEN_PATH, handlePresentOpen], [CHANGES_OPEN_PATH, handleChangesOpen]] as const) { + const routes = [ + [PRESENT_OPEN_PATH, 'POST', handlePresentOpen], [CHANGES_OPEN_PATH, 'POST', handleChangesOpen], [CHANGES_DIFF_PATH, 'GET', handleChangesDiff], + ] as const + for (const [path, method, handler] of routes) { ctx.connection.fetch.register({ path, - methods: ['POST'], + methods: [method], requestBody: 'buffered', fetch: (request) => { const task = handler(ctx, new Request(request, { @@ -152,6 +155,23 @@ function handleChangesSummary(ctx: Context, request: Request): Response { return Response.json({ turn, files, total, added, deleted } satisfies ChangesSummary, { headers: { 'cache-control': 'no-store' } }) } +/** One listed file's comparison; 404 once the Host no longer serves the summary or the index names no file. */ +async function handleChangesDiff(ctx: Context, request: Request): Promise { + const query = new URL(request.url).searchParams + const id = query.get('sessionId') + const seq = coordinate(query.get('seq')) + const index = coordinate(query.get('index')) + if (!id || seq === undefined || index === undefined) return new Response('Invalid changed file coordinates.', { status: 400 }) + try { + const diff = await ctx.workspaceChanges.diff(id as SessionId, seq, index, request.signal) + if (diff === undefined) return new Response('Change comparison unavailable.', { status: 404 }) + return Response.json(diff, { headers: { 'cache-control': 'no-store' } }) + } catch (error: unknown) { + request.signal.throwIfAborted() + return new Response('Change comparison unavailable.', { status: failureStatus(error) }) + } +} + async function handleChangesOpen(ctx: Context, request: Request): Promise { const query = new URL(request.url).searchParams const id = query.get('sessionId') diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts index 844fa14678..cb36fd6cf3 100644 --- a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -10,10 +10,13 @@ import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser import { SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query' -import type { WorkspaceChangedFile, WorkspaceChangesSummary } from '@deepseek-ai/dsh-workspace-changes/types' +import type { WorkspaceChangedFile, WorkspaceChangesSummary, WorkspaceFileDiff } from '@deepseek-ai/dsh-workspace-changes/types' import { afterEach, describe, expect, it, vi } from 'vitest' import { commonChangedFolder, registerPresentOpen } from '../src/present-open.ts' -import { changedFileUrl, changesSummaryUrl, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, isChangedFile, isChangesEvent, isChangesSummary } from '../src/changes.ts' +import { + changedFileUrl, changesDiffUrl, changesSummaryUrl, CHANGES_DIFF_PATH, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, isChangedFile, isChangesDiff, + isChangesEvent, isChangesSummary, +} from '../src/changes.ts' const cleanups: Array<() => Promise> = [] afterEach(async () => { @@ -50,7 +53,13 @@ async function fixture() { }) ctx.provide('sessionQuery', { readEvent } as never) const summary = vi.fn((sessionId: SessionId, seq: number) => sessionId === 'owner' && seq === 9 ? data : undefined) - ctx.provide('workspaceChanges', { summary }) + const comparison: WorkspaceFileDiff = { + kind: 'text', path: 'src/lib/a.ts', display: 'src/lib/a.ts', before: true, after: true, coarse: false, + hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }], + } + const diff = vi.fn(async (sessionId: SessionId, seq: number, index: number, _signal: AbortSignal) => + sessionId === 'owner' && seq === 9 && index === 0 ? comparison : undefined) + ctx.provide('workspaceChanges', { summary, diff }) const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) @@ -61,7 +70,8 @@ async function fixture() { const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' })) const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`)) - return { root, cwd, ctx, data, readEvent, open, read, opener, outside, summary } + const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`)) + return { root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary } } describe('change summary route', () => { @@ -82,6 +92,43 @@ describe('change summary route', () => { }) }) +describe('change comparison route', () => { + it('serves the Host-computed comparison, 404 once it is gone, and 500 when the read fails', async () => { + const { compare, comparison, diff } = await fixture() + expect(changesDiffUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_DIFF_PATH}?sessionId=owner&seq=9&index=0`) + const response = await compare() + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.json()).toEqual(comparison) + expect(diff).toHaveBeenLastCalledWith('owner', 9, 0, expect.any(AbortSignal)) + expect((await compare('?sessionId=owner&seq=9&index=1')).status).toBe(404) + expect((await compare('?sessionId=owner&seq=8&index=0')).status).toBe(404) + expect((await compare('?sessionId=other&seq=9&index=0')).status).toBe(404) + for (const bad of ['', '?seq=9&index=0', '?sessionId=owner&seq=9', '?sessionId=owner&seq=9&index=-1', '?sessionId=owner&seq=x&index=0']) { + expect((await compare(bad)).status).toBe(400) + } + diff.mockRejectedValueOnce(new Error('/private/objects')) + const failed = await compare() + expect(failed.status).toBe(500) + expect(await failed.text()).not.toContain('/private/objects') + }) + + it('validates served comparisons', () => { + const text = { kind: 'text', path: 'a', display: 'a', before: true, after: false, coarse: true, hunks: [] } + expect(isChangesDiff(text)).toBe(true) + expect(isChangesDiff({ ...text, hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 0, lines: ['-x', ' y', '+z'] }] })).toBe(true) + expect(isChangesDiff({ kind: 'binary', path: 'a', display: 'a' })).toBe(true) + expect(isChangesDiff({ kind: 'oversized', path: 'a', display: 'a' })).toBe(true) + expect(isChangesDiff({ kind: 'other', path: 'a', display: 'a' })).toBe(false) + expect(isChangesDiff({ kind: 'binary', path: '', display: 'a' })).toBe(false) + expect(isChangesDiff({ ...text, before: 'yes' })).toBe(false) + expect(isChangesDiff({ ...text, hunks: [{ oldStart: -1, oldLines: 1, newStart: 1, newLines: 0, lines: [] }] })).toBe(false) + expect(isChangesDiff({ ...text, hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 0, lines: ['x'] }] })).toBe(false) + expect(isChangesDiff({ ...text, hunks: [null] })).toBe(false) + expect(isChangesDiff(null)).toBe(false) + }) +}) + describe('changed files native open route', () => { it('opens a listed file inside or outside the workspace with its verified Host path', async () => { const { cwd, open, opener, outside } = await fixture() @@ -147,6 +194,8 @@ describe('changed files native open route', () => { it('validates served summaries and logged announcements', () => { expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: true })).toBe(true) expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: false })).toBe(false) + expect(isChangedFile({ path: 'a', display: 'a', added: 0, deleted: 0, oversized: true })).toBe(true) + expect(isChangedFile({ path: 'a', display: 'a', added: 0, deleted: 0, oversized: 1 })).toBe(false) expect(isChangedFile({ path: '', display: 'a', added: 1, deleted: 2 })).toBe(false) expect(isChangedFile({ path: 'a', display: '', added: 1, deleted: 2 })).toBe(false) expect(isChangedFile({ path: 'a', display: 'a', added: 1.5, deleted: 2 })).toBe(false) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 95aa2c265c..d039868f77 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -23,6 +23,7 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' +import type { DiffPreviewInjected } from '../src/client/DiffPreview.tsx' import { ChangesSummaryStore } from '../src/client/changes-summary.ts' import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts' import { PresentedOpenController } from '../src/client/present-open.ts' @@ -48,6 +49,7 @@ function openProps(controller = new PresentedOpenController(), summaries = new C select(controller.host.getSnapshot()), openPresented: vi.fn((...args: Parameters) => controller.open(...args)), openChanged: vi.fn((...args: Parameters) => controller.openChanged(...args)), + openChangesDiff: vi.fn(), usePresentedOpen: (select: (state: ReturnType) => T): T => select(controller.state.getSnapshot()), } @@ -430,8 +432,12 @@ describe('produced-file Turn data', () => { }) }) -const changedFile = (display: string, added = 1, deleted = 0, extra: { binary?: true; path?: string } = {}) => - ({ path: extra.path ?? display, display, added, deleted, ...extra.binary === true ? { binary: true as const } : {} }) +const changedFile = (display: string, added = 1, deleted = 0, extra: { binary?: true; oversized?: true; path?: string } = {}) => + ({ + path: extra.path ?? display, display, added, deleted, + ...extra.binary === true ? { binary: true as const } : {}, + ...extra.oversized === true ? { oversized: true as const } : {}, + }) const changesEvent = (seq: number, turn = 1) => at(seq, 'workspace/changes', { turn }) @@ -561,7 +567,7 @@ describe('ChangedFiles card', () => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading') }) - it('summarizes the turn, folds after three rows, and opens rows and the folder natively', () => { + it('summarizes the turn, folds after three rows, opens each row’s comparison, and opens the folder natively', () => { const { props, openFile, view } = renderCard() const card = view.container.querySelector('[data-changed-files]') if (!(card instanceof HTMLElement)) throw new Error('changed-files card missing') @@ -572,8 +578,9 @@ describe('ChangedFiles card', () => { expect(within(card).getByText('config/design-token')).toBeTruthy() expect(within(card).getByText('+42')).toBeTruthy() expect(within(card).queryByText('src/index.ts')).toBeNull() - fireEvent.click(within(card).getByRole('button', { name: 'Open config/feature-flags.json in default app' })) - expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, 1) + fireEvent.click(within(card).getByRole('button', { name: 'View changes to config/feature-flags.json' })) + expect(props.openChangesDiff).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, index: 1, display: 'config/feature-flags.json' }) + expect(props.openChanged).not.toHaveBeenCalled() fireEvent.click(within(card).getByRole('button', { name: 'Open the folder containing the changed files' })) expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, null) expect(openFile).not.toHaveBeenCalled() @@ -583,7 +590,9 @@ describe('ChangedFiles card', () => { fireEvent.click(expand) expect(within(card).getAllByRole('listitem')).toHaveLength(5) expect(within(card).getByText('binary')).toBeTruthy() - expect(within(card).getByRole('button', { name: 'Open ~/.zshrc in default app' }).getAttribute('title')).toBe('/home/u/.zshrc') + expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBe('/home/u/.zshrc') + fireEvent.click(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' })) + expect(props.openChangesDiff).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, index: 4, display: '~/.zshrc' }) const collapse = within(card).getByRole('button', { name: 'Collapse changed files' }) expect(collapse.getAttribute('aria-expanded')).toBe('true') expect(card.lastElementChild).toBe(collapse) @@ -591,7 +600,7 @@ describe('ChangedFiles card', () => { expect(within(card).getAllByRole('listitem')).toHaveLength(3) }) - it('previews rows in the Sidebar and offers no folder action without a desktop', () => { + it('still opens each row’s comparison and offers no folder action without a desktop', () => { const controller = new PresentedOpenController() const { openFile, props, view } = renderCard(controller, zh) controller.host.set('error') @@ -601,39 +610,33 @@ describe('ChangedFiles card', () => { view.rerender() expect(view.getByText('已编辑 11 个文件')).toBeTruthy() expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull() - fireEvent.click(view.getByRole('button', { name: '在侧边栏打开 config/design-token' })) - expect(openFile).toHaveBeenCalledWith('config/design-token') + fireEvent.click(view.getByRole('button', { name: '查看 config/design-token 的改动' })) + expect(props.openChangesDiff).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, index: 0, display: 'config/design-token' }) + expect(openFile).not.toHaveBeenCalled() expect(props.openChanged).not.toHaveBeenCalled() expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件') }) - it('shows row and folder gesture states in place of the counts', () => { + it('shows folder gesture states in place of the totals and keeps row counts through native opens', () => { const controller = new PresentedOpenController() controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opening', '/api/changes.open?sessionId=child-session&seq=5&index=1': 'error', - '/api/changes.open?sessionId=child-session&seq=5&index=2': 'nativeUnavailable', '/api/changes.open?sessionId=child-session&seq=5': 'opened', }) - const { view, openFile, props } = renderCard(controller) - expect(view.getByText(en['presented.opening'])).toBeTruthy() - expect((view.getByRole('button', { name: 'Open config/design-token in default app' }) as HTMLButtonElement).disabled).toBe(true) - expect(view.getByText(en['presented.error']).closest('[data-error]')).toBeTruthy() - expect(view.getByText(en['presented.nativeUnavailable'])).toBeTruthy() + const { view } = renderCard(controller) + // Row gestures belong to the comparison tab; the card keeps every row's counts. + expect(view.queryByText(en['presented.opening'])).toBeNull() + expect(view.queryByText(en['presented.error'])).toBeNull() + expect(view.getByText('+42')).toBeTruthy() + expect(view.getByText('+143')).toBeTruthy() // A completed folder open leaves the summed counts in place. expect(view.getByText('+1,232')).toBeTruthy() - // A row without a verified Host path previews in the Sidebar instead of retrying the native open. - fireEvent.click(view.getByRole('button', { name: 'Open config/launch-plan.yaml in sidebar' })) - expect(openFile).toHaveBeenCalledWith('config/launch-plan.yaml') - expect(props.openChanged).not.toHaveBeenCalled() view.unmount() - controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'opening', '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opened' }) + controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'opening' }) const { view: pending } = renderCard(controller) expect(pending.getByText(en['changes.folderOpening'])).toBeTruthy() expect((pending.getByRole('button', { name: 'Open the folder containing the changed files' }) as HTMLButtonElement).disabled).toBe(true) - // A completed row open shows its counts again rather than a lasting acknowledgement. - expect(pending.queryByText(en['presented.opened'])).toBeNull() - expect(pending.getByText('+42')).toBeTruthy() pending.unmount() controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'error' }) const { view: failed } = renderCard(controller) @@ -641,9 +644,10 @@ describe('ChangedFiles card', () => { }) it('renders without a fold for three files or fewer and beside delivery cards', () => { - const short = servedStore({ turn: 1, files: files.slice(0, 2), total: 2, added: 185, deleted: 43 }) + const short = servedStore({ turn: 1, files: [...files.slice(0, 1), changedFile('huge.bin', 0, 0, { oversized: true })], total: 2, added: 185, deleted: 43 }) const { view } = renderCard(new PresentedOpenController(), en, { changes, presented: [{ path: 'report.pdf', seq: 6, index: 0 }] as never[] }, short) expect(view.getByText('Edited 2 files')).toBeTruthy() + expect(view.getByText('too large')).toBeTruthy() expect(view.queryByRole('button', { name: /Show all|Collapse changed/ })).toBeNull() expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(1) expect(view.container.querySelector('[data-presented-files-row]')?.parentElement?.getAttribute('data-after-changes')).toBe('true') @@ -686,8 +690,17 @@ describe('plugin registration', () => { // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', - children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, 'tool.call.toolview': { kind: 'keyed', scope: 'session' } }, + children: { + 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, + 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, + 'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session' }, + }, } as never, () => null) + const registerTab = vi.fn(() => () => { registered = undefined }) + let registered: unknown + ctx.provide('sidebarRightTabs', { register: (definition: unknown) => { registered = definition; return registerTab() } } as never) + const openResource = vi.fn() + ctx.provide('sidebarRight', { openResource } as never) // ui-theme's Appearance row binds a durable scope through these two. const session = { canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }), @@ -707,6 +720,9 @@ describe('plugin registration', () => { expect(entry).toBeDefined() expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(1) expect(entry?.inject).toBeDefined() + expect(registered).toMatchObject({ kind: 'changes-diff', patterns: ['dsh-resource://changes-diff/**'] }) + const [tabEntry] = ctx.slots.entries('sidebar.right.pane.tab') + expect(tabEntry?.options.key).toBe('@deepseek-ai/dsh-client-ui-deliverables') // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. @@ -750,6 +766,15 @@ describe('plugin registration', () => { expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') await face.openChanged(SessionId('child-session'), 5, null) expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5']).toBe('opened') + face.openChangesDiff({ sessionId: SessionId('child-session'), seq: 5, index: 1, display: 'src/a.ts' }) + expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-diff/session/child-session/5/1/src%2Fa.ts') + const tabFace = tabEntry!.inject!(SessionId('child-session') as never) as unknown as DiffPreviewInjected + fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })) + await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1) + expect(tabFace.hooks.changesDiff.getSnapshot()['/api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) + expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost) + ctx.emit('connection/reset') + expect(tabFace.hooks.changesDiff.getSnapshot()).toEqual({}) // A turn that produced nothing yields no vocabulary at all. expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() @@ -763,6 +788,8 @@ describe('plugin registration', () => { unsubscribe() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0) + expect(ctx.slots.entries('sidebar.right.pane.tab')).toHaveLength(0) + expect(registered).toBeUndefined() // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) diff --git a/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx b/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx new file mode 100644 index 0000000000..589aedb324 --- /dev/null +++ b/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom +/** The comparison tab type: its addresses, its Host reads, and the states its body draws. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { changesDiffAddress, changesDiffUrl, parseChangesDiffAddress, type ChangesDiff } from '../src/changes.ts' +import { ChangesDiffStore } from '../src/client/changes-diff.ts' +import { DiffPreview, hunkRows, type DiffPreviewInjected, type DiffPreviewProps } from '../src/client/DiffPreview.tsx' +import { changesDiffDefinition } from '../src/client/diff-definition.ts' +import { PresentedOpenController } from '../src/client/present-open.ts' +import { en, zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +const SESSION = SessionId('viewed') +const COORDINATES = { sessionId: SESSION, seq: 5, index: 1, display: 'src/app/main.ts' } +const URL_ = changesDiffUrl(SESSION, 5, 1) + +const text: ChangesDiff = { + kind: 'text', path: 'src/app/main.ts', display: 'src/app/main.ts', before: true, after: true, coarse: false, + hunks: [ + { oldStart: 1, oldLines: 3, newStart: 1, newLines: 4, lines: [' a', '-b', '+B', '+c', ' d'] }, + { oldStart: 10, oldLines: 1, newStart: 11, newLines: 1, lines: ['-x', '+y'] }, + ], +} + +describe('comparison addresses', () => { + it('round-trips coordinates and titles the tab by the file name', () => { + const address = changesDiffAddress({ ...COORDINATES, display: 'dir/we ird#name.ts' }) + expect(address).toBe('dsh-resource://changes-diff/session/viewed/5/1/dir%2Fwe%20ird%23name.ts') + expect(parseChangesDiffAddress(address)).toEqual({ ...COORDINATES, display: 'dir/we ird#name.ts' }) + const definition = changesDiffDefinition() + expect(definition).toMatchObject({ kind: 'changes-diff', priority: 'builtin', patterns: ['dsh-resource://changes-diff/**'] }) + expect(definition.canOpen?.(address)).toBe(true) + expect(definition.title(address)).toBe('we ird#name.ts') + for (const bad of [ + 'dsh-resource://file/session/viewed/a.ts', 'dsh-resource://changes-diff/session/viewed/5/1', + 'dsh-resource://changes-diff/session//5/1/a', 'dsh-resource://changes-diff/session/viewed/x/1/a', + 'dsh-resource://changes-diff/session/viewed/5/1/', 'dsh-resource://changes-diff/session/viewed/5/1/%E0%A4%A', + ]) { + expect(parseChangesDiffAddress(bad)).toBeUndefined() + expect(definition.canOpen?.(bad)).toBe(false) + expect(definition.title(bad)).toBe(bad) + } + }) +}) + +describe('ChangesDiffStore', () => { + it('keeps served and missing comparisons, retries failures, and forgets on reset and disposal', async () => { + const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise>() + vi.stubGlobal('fetch', fetcher) + const store = new ChangesDiffStore() + fetcher.mockResolvedValueOnce(Response.json(text)) + await store.load(SESSION, 5, 1) + expect(store.state.getSnapshot()[URL_]).toEqual(text) + await store.load(SESSION, 5, 1) + expect(fetcher).toHaveBeenCalledTimes(1) + fetcher.mockResolvedValueOnce(new Response('gone', { status: 404 })) + await store.load(SESSION, 5, 2) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 2)]).toBe('missing') + await store.load(SESSION, 5, 2) + expect(fetcher).toHaveBeenCalledTimes(2) + fetcher.mockResolvedValueOnce(new Response('boom', { status: 500 })) + await store.load(SESSION, 5, 3) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error') + fetcher.mockResolvedValueOnce(Response.json({ kind: 'text' })) + await store.load(SESSION, 5, 3) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error') + fetcher.mockRejectedValueOnce(new Error('offline')) + await store.load(SESSION, 5, 3) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error') + fetcher.mockResolvedValueOnce(Response.json({ kind: 'oversized', path: 'p', display: 'p' })) + await store.load(SESSION, 5, 3) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toEqual({ kind: 'oversized', path: 'p', display: 'p' }) + store.reset() + expect(store.state.getSnapshot()).toEqual({}) + let settle!: (response: Response) => void + fetcher.mockReturnValueOnce(new Promise((resolve) => { settle = resolve })) + const stale = store.load(SESSION, 5, 1) + expect(store.state.getSnapshot()[URL_]).toBe('loading') + store.reset() + settle(Response.json(text)) + await stale + expect(store.state.getSnapshot()[URL_]).toBeUndefined() + fetcher.mockReturnValueOnce(new Promise((resolve) => { settle = resolve })) + const late = store.load(SESSION, 5, 1) + const disposal = store.dispose() + settle(Response.json(text)) + await Promise.all([late, disposal]) + expect(store.state.getSnapshot()[URL_]).toBe('loading') + await store.load(SESSION, 6, 0) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 6, 0)]).toBeUndefined() + }) +}) + +describe('DiffPreview', () => { + function mount( + diffs = new ChangesDiffStore(), controller = new PresentedOpenController(), locale = en, address = changesDiffAddress(COORDINATES), + ) { + const injected = { + loadChangesDiff: vi.fn(() => Promise.resolve()), + reloadPresentedHost: vi.fn(() => Promise.resolve()), + openChanged: vi.fn(() => Promise.resolve()), + } + const runtime = { + useTabInfo: () => ({ tab: { contentId: address } }), + useChangesDiff: (select: (state: ReturnType) => T): T => select(diffs.state.getSnapshot()), + usePresentedOpen: (select: (state: ReturnType) => T): T => + select(controller.state.getSnapshot()), + usePresentedHost: (select: (state: ReturnType) => T): T => + select(controller.host.getSnapshot()), + t: makeTranslate(locale), + ...injected, + } as unknown as DiffPreviewProps + const view = render() + return { view, injected, rerender: () => { view.rerender() } } + } + + it('numbers hunk lines on the side they belong to', () => { + expect(hunkRows(text.hunks[0]!)).toEqual([ + { kind: 'context', old: 1, new: 1, text: 'a' }, + { kind: 'del', old: 2, new: undefined, text: 'b' }, + { kind: 'add', old: undefined, new: 2, text: 'B' }, + { kind: 'add', old: undefined, new: 3, text: 'c' }, + { kind: 'context', old: 3, new: 4, text: 'd' }, + ]) + }) + + it('asks for the comparison once, then draws its hunks with line numbers and the path header', () => { + const diffs = new ChangesDiffStore() + const { view, injected, rerender } = mount(diffs) + expect(injected.loadChangesDiff).toHaveBeenCalledWith('viewed', 5, 1) + expect(injected.reloadPresentedHost).toHaveBeenCalledTimes(1) + expect(view.getByRole('status').textContent).toBe(en['diff.loading']) + expect(view.getByTitle('src/app/main.ts').textContent).toBe('src/app/main.ts') + diffs.state.set({ [URL_]: text }) + rerender() + expect(injected.loadChangesDiff).toHaveBeenCalledTimes(1) + const root = view.container.querySelector('[data-changes-diff]') + expect(root?.getAttribute('data-diff-state')).toBe('text') + expect(view.getByText('@@ -1,3 +1,4 @@')).toBeTruthy() + const lines = [...view.container.querySelectorAll('[data-diff-line]')] + expect(lines.map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'add', 'context', 'del', 'add']) + expect(lines[1]?.textContent).toBe('2-b') + expect(lines[2]?.textContent).toBe('2+B') + expect(lines[4]?.textContent).toBe('34 d') + expect(view.queryByText(en['diff.created'])).toBeNull() + // Without a desktop the header carries no native-open control. + expect(view.queryByRole('button')).toBeNull() + }) + + it('states created, deleted, unchanged, and coarse comparisons, and the binary, oversized, missing, and failed reads', () => { + const diffs = new ChangesDiffStore() + const { view, injected, rerender } = mount(diffs, new PresentedOpenController(), zh) + diffs.state.set({ [URL_]: { ...text, before: false, coarse: true } }) + rerender() + expect(view.getByText(zh['diff.created'])).toBeTruthy() + expect(view.container.querySelector('[data-diff-coarse]')?.textContent).toBe(zh['diff.coarse']) + diffs.state.set({ [URL_]: { ...text, after: false } }) + rerender() + expect(view.getByText(zh['diff.deleted'])).toBeTruthy() + diffs.state.set({ [URL_]: { ...text, hunks: [] } }) + rerender() + expect(view.getByText(zh['diff.unchanged'])).toBeTruthy() + diffs.state.set({ [URL_]: { kind: 'binary', path: 'p', display: 'other/name.bin' } }) + rerender() + expect(view.getByText(zh['diff.binary'])).toBeTruthy() + expect(view.getByTitle('other/name.bin')).toBeTruthy() + diffs.state.set({ [URL_]: { kind: 'oversized', path: 'p', display: 'p' } }) + rerender() + expect(view.getByText(zh['diff.oversized'])).toBeTruthy() + diffs.state.set({ [URL_]: 'missing' }) + rerender() + expect(view.getByText(zh['diff.missing'])).toBeTruthy() + diffs.state.set({ [URL_]: 'error' }) + rerender() + expect(view.getByText(zh['diff.error'])).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: zh['presented.retry'] })) + expect(injected.loadChangesDiff).toHaveBeenCalledTimes(2) + }) + + it('offers the native open only with a desktop and shows its pending and failed states', () => { + const diffs = new ChangesDiffStore() + diffs.state.set({ [URL_]: text }) + const controller = new PresentedOpenController() + controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) + const { view, injected, rerender } = mount(diffs, controller) + expect(injected.reloadPresentedHost).not.toHaveBeenCalled() + fireEvent.click(view.getByRole('button', { name: en['diff.openNative'] })) + expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1) + controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) + rerender() + expect((view.getByRole('button', { name: en['presented.opening'] }) as HTMLButtonElement).disabled).toBe(true) + controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) + rerender() + expect(view.getByRole('button', { name: en['diff.openNativeError'] })).toBeTruthy() + controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) + rerender() + expect(view.queryByRole('button')).toBeNull() + controller.state.set({}) + controller.host.set({ name: 'server', available: false, fileManager: null }) + rerender() + expect(view.queryByRole('button')).toBeNull() + controller.host.set('error') + rerender() + expect(view.queryByRole('button')).toBeNull() + }) + + it('refuses an address it did not mint', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + expect(() => mount(undefined, undefined, en, 'dsh-resource://file/session/viewed/a.ts')).toThrow('not a comparison address') + error.mockRestore() + }) +}) diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json index c349a604c1..c2f4d6bec6 100644 --- a/packages/client/ui-deliverables/tsconfig.client.json +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -39,6 +39,9 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-sidebar-right" + }, { "path": "../ui-slots" }, diff --git a/packages/deliverables/README.i18n.yaml b/packages/deliverables/README.i18n.yaml index 89304481c4..ac28139081 100644 --- a/packages/deliverables/README.i18n.yaml +++ b/packages/deliverables/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/deliverables/README.md -README.md: 92b9dfa95c4b5ded19fa2753d51551bafbd03fed -README.zh.md: ff8e0fe0eba4ed02504e30765ede58c95b78daa0 +README.md: 3666cbbbe1695a95fe33497c22dd94f502a7f292 +README.zh.md: 72dbf338f8758ed251287461d13b18df34dff454 diff --git a/packages/deliverables/README.md b/packages/deliverables/README.md index 92b9dfa95c..3666cbbbe1 100644 --- a/packages/deliverables/README.md +++ b/packages/deliverables/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The deliverables family records what a turn hands to the user as durable Session events that only clients read: the `present` tool declares final files the model delivered, and the workspace-changes recorder captures the files a turn changed with their line counts from git snapshots. The Web [deliverables plugin](../client/ui-deliverables/README.md) renders both at the end of a turn. Choose this family for a product that shows delivered files and per-turn changes; `present` needs `ctx.tools` and `ctx.fs`, the recorder needs `ctx.subprocess` and a git executable. +The deliverables family records what a turn hands to the user as durable Session events that only clients read: the `present` tool declares final files the model delivered, and the workspace-changes recorder captures the files a turn changed with their line counts from git snapshots and whole-file captures, and serves each file's comparison. The Web [deliverables plugin](../client/ui-deliverables/README.md) renders both at the end of a turn. Choose this family for a product that shows delivered files and per-turn changes; `present` needs `ctx.tools` and `ctx.fs`, the recorder needs `ctx.subprocess` and a git executable. ## Table of Contents @@ -25,7 +25,7 @@ The deliverables family records what a turn hands to the user as durable Session | Package | Role | ctx key | |---|---|---| | [`tool-present`](tool-present/README.md) | Declares existing files as final deliverables through the `present` tool | registers on `ctx.tools` | -| [`workspace-changes`](workspace-changes/README.md) | Summarizes each top-level turn's changed files from git working-tree snapshots | provides `ctx.workspaceChanges`; listens to `session/event`, appends `workspace/changes` | +| [`workspace-changes`](workspace-changes/README.md) | Summarizes each top-level turn's changed files from git working-tree snapshots and whole-file captures, and serves their comparisons | provides `ctx.workspaceChanges`; listens to `session/event`, appends `workspace/changes` | ----- diff --git a/packages/deliverables/README.zh.md b/packages/deliverables/README.zh.md index ff8e0fe0eb..72dbf338f8 100644 --- a/packages/deliverables/README.zh.md +++ b/packages/deliverables/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -deliverables 系列把一轮交给用户的内容记录为只有客户端读取的持久 Session 事件:`present` 工具声明模型交付的最终文件,workspace-changes 记录器用 git 快照捕获一轮改动的文件及其行数。Web 的[交付插件](../client/ui-deliverables/README.zh.md)在轮次末尾渲染两者。需要展示交付文件和每轮改动的产品选择本系列;`present` 需要 `ctx.tools` 与 `ctx.fs`,记录器需要 `ctx.subprocess` 和 git 可执行文件。 +deliverables 系列把一轮交给用户的内容记录为只有客户端读取的持久 Session 事件:`present` 工具声明模型交付的最终文件,workspace-changes 记录器用 git 快照和整文件捕获记录一轮改动的文件及其行数,并提供每个文件的对比。Web 的[交付插件](../client/ui-deliverables/README.zh.md)在轮次末尾渲染两者。需要展示交付文件和每轮改动的产品选择本系列;`present` 需要 `ctx.tools` 与 `ctx.fs`,记录器需要 `ctx.subprocess` 和 git 可执行文件。 ## 目录 @@ -25,7 +25,7 @@ deliverables 系列把一轮交给用户的内容记录为只有客户端读取 | 包 | 角色 | ctx key | |---|---|---| | [`tool-present`](tool-present/README.zh.md) | 通过 `present` 工具把已有文件声明为最终交付物 | 注册到 `ctx.tools` | -| [`workspace-changes`](workspace-changes/README.zh.md) | 用 git 工作树快照汇总每个顶层轮次改动的文件 | 提供 `ctx.workspaceChanges`;监听 `session/event`,追加 `workspace/changes` | +| [`workspace-changes`](workspace-changes/README.zh.md) | 用 git 工作树快照和整文件捕获汇总每个顶层轮次改动的文件,并提供其对比 | 提供 `ctx.workspaceChanges`;监听 `session/event`,追加 `workspace/changes` | ----- diff --git a/packages/deliverables/workspace-changes/README.i18n.yaml b/packages/deliverables/workspace-changes/README.i18n.yaml index c60f0362fe..24b01fb067 100644 --- a/packages/deliverables/workspace-changes/README.i18n.yaml +++ b/packages/deliverables/workspace-changes/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/deliverables/workspace-changes/README.md -README.md: 5c304e3112c4d58a5baec54547e7341fde8e1dec -README.zh.md: 428c3ce3d912cc17018cc39f552f1748b5ae56ff +README.md: 80983656ac679202695abeff8a505e549785f4d2 +README.zh.md: dfe9e82abd13d408426cb8f0800dccbd25073c0c diff --git a/packages/deliverables/workspace-changes/README.md b/packages/deliverables/workspace-changes/README.md index 5c304e3112..80983656ac 100644 --- a/packages/deliverables/workspace-changes/README.md +++ b/packages/deliverables/workspace-changes/README.md @@ -1,5 +1,5 @@ --- -description: "Summarize each top-level turn's changed files from git working-tree snapshots, announce them with a workspace/changes Session event, and serve the summary while the Session lives; configuration, repository requirement, and coverage rules." +description: "Summarize each top-level turn's changed files from git working-tree snapshots and whole-file captures around file-tool edits, announce them with a workspace/changes Session event, and serve the summary and per-file comparisons while the Session lives; configuration, repository requirement, and coverage rules." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This plugin summarizes which files each top-level turn changed, with per-file added and deleted line counts: git snapshots of the working tree at turn start and turn end are diffed, and the file-tool edits git does not cover are added. Outside a git repository, or without git, the summary lists the file-tool edits only. The Session log receives one `workspace/changes` event naming the turn; the summary stays on the Host, served through the `workspaceChanges` service until the Session is disposed. The Web changed-files card renders it; the model never sees it. +This plugin summarizes which files each top-level turn changed, with per-file line counts, and serves each listed file's turn-start and turn-end comparison. Git snapshots of the working tree at turn start and turn end are diffed; every file a file tool edits is copied whole before its first edit and again at turn end, covering the files git does not. Without a repository or git, only file-tool edits are listed. The Session log receives one `workspace/changes` event naming the turn; summaries and comparisons stay on the Host until the Session is disposed. The Web changed-files card renders them. ## Table of Contents @@ -38,12 +38,14 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the | `timeoutMs` | `30000` | Milliseconds one git command may run before the turn's record is abandoned | | `outputMaxBytes` | `8388608` | Bytes of git output retained per command; a larger diff listing abandons the record | | `maxFiles` | `500` | Maximum files carried by one summary; `total` still reports the complete count | +| `maxFileBytes` | `2097152` | Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; a larger file is listed without counts or comparison | +| `diffTimeoutMs` | `100` | Milliseconds a line comparison may run before it degrades to whole-file replacement | Every Session with a working directory and no subagent origin is recorded; subagent Sessions are not. Snapshots are written through a private index into a temporary object directory owned by the Session, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. Session disposal removes the directory. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. A working directory outside any git repository takes no snapshots. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — no repository is located either, and the plugin logs that once. Either way the summary lists the file-tool edits alone, as described next, with the working directory as the workspace; shell edits are absent. -Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results, or from the call's own arguments when the result persists none — a `write` that creates a file and every `str_replace_editor` mutation: files matching an ignore pattern and files outside the repository. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded. +Before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the file at its path into the Session's temporary directory, once per path per turn, and copies it again at turn end; the copies are named by the SHA-1 of their bytes, so identical content is stored once. This needs no git. Paths the snapshots cover keep their git counts; the copies serve the other paths — files matching an ignore pattern, files outside the repository, and every file-tool edit when there is no snapshot — with counts from a line comparison of the two copies, so repeated edits to one file count once and a shell edit after a file-tool edit is included. A path whose content is unchanged at turn end is not listed. A copy larger than `maxFileBytes` is not stored: the file is listed with `oversized` and no counts. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. Changes made only through shell commands outside the snapshot coverage are not recorded. -Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The `workspace/changes` event carries only the turn number; `ctx.workspaceChanges.summary(sessionId, seq)` returns the summary the event with that sequence announced, or undefined once the Session is disposed or when this Host process never recorded it. A conversation reopened after a Host restart therefore has no card for its earlier turns. +Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The `workspace/changes` event carries only the turn number; `ctx.workspaceChanges.summary(sessionId, seq)` returns the summary the event with that sequence announced, or undefined once the Session is disposed or when this Host process never recorded it. `ctx.workspaceChanges.diff(sessionId, seq, index, signal)` compares the listed file at that index: hunks with three context lines from the two snapshot trees or the two copies, `binary` for a side git reported as binary or that holds a NUL byte, `oversized` for a side larger than `maxFileBytes`. A line comparison that runs longer than `diffTimeoutMs` degrades to one hunk replacing every line, marked `coarse`. A conversation reopened after a Host restart therefore has no card, and no comparison, for its earlier turns. ----- @@ -53,11 +55,11 @@ Each file carries a durable `path` — relative to the working directory inside
    Implementation internals — click to expand -One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository once per Session and the Session's temporary object directory is created, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the temporary directory and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/call` events keep each mutation call's argument-derived hunks and `tool/result` events collect the persisted ones, preferring the latter. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, the appended event, and the summary kept under the event's sequence. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read. +One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository once per Session and the Session's temporary object directory is created, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the temporary directory and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline; the same step queues the whole-file capture of the path a `write`, `edit`, or mutating `str_replace_editor` call names, so the copy precedes the edit, and `tool/result` events only mark that the turn has results to record. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for captured paths inside the work tree, a second copy and line comparison of each uncovered path, the appended event, and the summary kept under the event's sequence beside each listed file's two content sources, a path in a snapshot tree or a copy. A comparison is computed when asked for: `ls-tree -l` locates and sizes a snapshot side and `cat-file blob` reads it under `maxFileBytes`, copies are read from disk, and both sides go through the same line comparison with its timeout. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read. Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work, forget the summaries, and remove the temporary directory. -**Runtime invariant:** No companion is published. Event listeners are effect-owned and the recorder owns both the summaries and the snapshot trees for its Session's lifetime; no independent observation can diverge from them. +**Runtime invariant:** No companion is published. Event listeners are effect-owned and the recorder owns the summaries, the snapshot trees, and the captured copies for its Session's lifetime; no independent observation can diverge from them.
    @@ -83,13 +85,14 @@ Nothing here enters a model request, so provider cache reuse is unaffected. -- Summaries and snapshot trees live only as long as their Session in this Host process; earlier turns of a conversation reopened after a Host restart have no card. This is the decided behavior: a card whose content the Host can no longer open is not shown. +- Summaries, snapshot trees, and captured copies live only as long as their Session in this Host process; earlier turns of a conversation reopened after a Host restart have no card and no comparison. This is the decided behavior: a card whose content the Host can no longer open is not shown. - Two git features still write into the repository's own git directory during a snapshot: `core.splitIndex` writes `sharedindex.*` files, and git-lfs runs its clean filter on changed files and stores their objects under `.git/lfs`. - git 2.13 or later is required for `rev-parse --absolute-git-dir`; an unsupported repository format or another git failure abandons the turn with a warning rather than being treated as a plain directory. - The first snapshot of a Session writes every untracked, non-ignored file of the work tree into the Session's temporary directory; a repository without a `.gitignore` that carries large build outputs costs that much temporary space until the Session is disposed. - Edits the user makes during a turn are attributed to that turn. - A working directory outside any git repository lists file-tool edits only, so shell edits are missing from its card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably. -- Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only. +- Outside snapshot coverage only paths a file tool names are captured: a file only a shell command changes there is absent, and a file both changed before its first file-tool call is compared from that call onward. +- Every file-tool edit copies its whole file once per turn, up to `maxFileBytes`, even for paths the snapshots also cover; the copies go with the Session's temporary directory. - Windows paths keep native separators in `path`; `display` is always slash-separated. diff --git a/packages/deliverables/workspace-changes/README.zh.md b/packages/deliverables/workspace-changes/README.zh.md index 428c3ce3d9..dfe9e82abd 100644 --- a/packages/deliverables/workspace-changes/README.zh.md +++ b/packages/deliverables/workspace-changes/README.zh.md @@ -1,5 +1,5 @@ --- -description: "用 git 工作树快照汇总每个顶层轮次改动的文件,以 workspace/changes Session 事件宣告,并在 Session 存活期间提供摘要;配置、仓库要求与覆盖规则。" +description: "用 git 工作树快照和文件工具编辑前后的整文件捕获汇总每个顶层轮次改动的文件,以 workspace/changes Session 事件宣告,并在 Session 存活期间提供摘要和逐文件对比;配置、仓库要求与覆盖规则。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本插件汇总每个顶层轮次改动了哪些文件、每个文件的增删行数:比较轮次开始和结束时的 git 工作树快照,再补上 git 覆盖不到的文件工具编辑。不在 git 仓库内或没有 git 时,摘要只列文件工具的编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要留在 Host 上,通过 `workspaceChanges` 服务提供,直到 Session 释放。Web 的改动文件卡片渲染它;模型看不到它。 +本插件汇总每个顶层轮次改动了哪些文件、每个文件的行数,并提供每个所列文件在轮次开始与结束时的内容对比。比较轮次开始和结束时的 git 工作树快照;文件工具编辑的每个文件在首次编辑之前和轮次结束时各复制一份整文件,以此覆盖 git 覆盖不到的文件。没有仓库或没有 git 时,只列文件工具的编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要和对比留在 Host 上,直到 Session 释放。Web 的改动文件卡片渲染它们。 ## 目录 @@ -38,12 +38,14 @@ kind: "package-reference" | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 | | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 | | `maxFiles` | `500` | 单份摘要携带的最大文件数;`total` 仍报告完整数量 | +| `maxFileBytes` | `2097152` | 文件在文件工具编辑前后被捕获、或从快照读出做对比所允许的最大字节数;更大的文件只列出,不带行数和对比 | +| `diffTimeoutMs` | `100` | 逐行对比允许运行的毫秒数,超时后退化为整文件替换 | 有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。不在任何 git 仓库内的工作目录不做快照。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——同样定位不到仓库,插件记录一次日志。两种情况下摘要都只列下文所述的文件工具编辑,并以工作目录作为工作区;shell 的改动不会出现。 -文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入,结果没有持久化 hunk 时则取调用自身的参数,也就是新建文件的 `write` 和 `str_replace_editor` 的每一种修改:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。 +在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把该路径上的文件复制到 Session 的临时目录,每轮每个路径只复制第一次,轮次结束时再复制一次;副本按其字节的 SHA-1 命名,相同内容只存一份。这一步不需要 git。快照覆盖到的路径保留 git 的行数;其余路径由副本提供,也就是匹配忽略模式的文件、仓库之外的文件,以及没有快照时的每一次文件工具编辑,行数来自两份副本的逐行对比,因此同一文件的重复编辑只计一次,文件工具编辑之后的 shell 改动也包含在内。轮次结束时内容没有变化的路径不会列出。超过 `maxFileBytes` 的副本不会保存:该文件列出时带 `oversized`,没有行数。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。快照覆盖范围之外只通过 shell 命令做出的改动不会被记录。 -每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。`workspace/changes` 事件只携带轮号;`ctx.workspaceChanges.summary(sessionId, seq)` 返回该序号的事件宣告的摘要,Session 已释放或本 Host 进程从未记录时返回 undefined。因此 Host 重启后重新打开的对话,先前轮次没有卡片。 +每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。`workspace/changes` 事件只携带轮号;`ctx.workspaceChanges.summary(sessionId, seq)` 返回该序号的事件宣告的摘要,Session 已释放或本 Host 进程从未记录时返回 undefined。`ctx.workspaceChanges.diff(sessionId, seq, index, signal)` 对比该下标所列的文件:从两棵快照树或两份副本得出带三行上下文的 hunk;git 报告为二进制或某一侧含 NUL 字节时返回 `binary`;某一侧超过 `maxFileBytes` 时返回 `oversized`。逐行对比运行超过 `diffTimeoutMs` 时退化为一个替换全部行的 hunk,并标记 `coarse`。因此 Host 重启后重新打开的对话,先前轮次既没有卡片也没有对比。 ----- @@ -53,11 +55,11 @@ kind: "package-reference"
    实现细节——点击展开 -每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/call` 事件保留每个修改调用由参数推出的 hunk,`tool/result` 事件收集持久化的 hunk,后者优先。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`、追加事件,以及按事件序号保存摘要。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。 +每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生;同一步骤还排入对 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用所指路径的整文件捕获,因此副本先于编辑,`tool/result` 事件只标记本轮有结果需要记录。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内已捕获路径的 `check-ignore`、对每个未覆盖路径的第二次复制与逐行对比、追加事件,以及按事件序号保存摘要和每个所列文件两侧的内容来源,即快照树中的路径或一份副本。对比在被请求时计算:`ls-tree -l` 定位快照一侧并取其大小,`cat-file blob` 在 `maxFileBytes` 之内读出,副本从磁盘读取,两侧随后走同一个带超时的逐行对比。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。 git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。 -**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,记录器在其 Session 存活期间同时拥有摘要与快照树;没有独立观察会与它们分歧。 +**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,记录器在其 Session 存活期间同时拥有摘要、快照树与捕获的副本;没有独立观察会与它们分歧。
    @@ -83,13 +85,14 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ -- 摘要与快照树只在本 Host 进程内随其 Session 存活;Host 重启后重新打开的对话,先前轮次没有卡片。这是既定行为:Host 已经打不开内容的卡片不显示。 +- 摘要、快照树与捕获的副本只在本 Host 进程内随其 Session 存活;Host 重启后重新打开的对话,先前轮次既没有卡片也没有对比。这是既定行为:Host 已经打不开内容的卡片不显示。 - 有两个 git 功能在快照期间仍会写入仓库自己的 git 目录:`core.splitIndex` 会写 `sharedindex.*` 文件,git-lfs 会对改动文件运行 clean 过滤器并把对象存到 `.git/lfs` 下。 - 需要 git 2.13 或更高版本以支持 `rev-parse --absolute-git-dir`;不支持的仓库格式或其他 git 失败会带着警告放弃本轮,而不是被当成普通目录。 - Session 的首次快照会把工作树里所有未跟踪且未被忽略的文件写进 Session 的临时目录;没有 `.gitignore` 却带着大体积构建产物的仓库,在 Session 释放前会占用同等的临时空间。 - 用户在轮次进行中自己做的编辑会被算到该轮。 - 不在任何 git 仓库内的工作目录只列文件工具的编辑,卡片里因此没有 shell 改动;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。 -- 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。 +- 快照覆盖范围之外只捕获文件工具点名的路径:那里只被 shell 命令改动的文件不会出现,在首次文件工具调用之前被两者都改过的文件从该调用起开始对比。 +- 每次文件工具编辑都会把整个文件复制一次,每轮每个路径一次,上限 `maxFileBytes`,即使快照也覆盖该路径;副本随 Session 的临时目录一起删除。 - Windows 路径在 `path` 中保留原生分隔符;`display` 始终用斜杠分隔。 diff --git a/packages/deliverables/workspace-changes/src/capture.ts b/packages/deliverables/workspace-changes/src/capture.ts new file mode 100644 index 0000000000..907e1af41a --- /dev/null +++ b/packages/deliverables/workspace-changes/src/capture.ts @@ -0,0 +1,88 @@ +/** + * Whole-file captures around file-tool edits: the content of a path before + * the turn's first mutation of it and at turn end, stored content-addressed + * under the Session's temporary directory so both sides of a comparison + * survive later edits without depending on git. + */ +import { createHash } from 'node:crypto' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +/** Bytes git inspects for a NUL byte before treating content as binary. */ +const BINARY_PROBE_BYTES = 8000 + +/** One captured side of a path. */ +export type Capture = + /** No file at the path. */ + | { kind: 'absent' } + /** A regular file larger than the configured cap; its content is not stored. */ + | { kind: 'oversized' } + /** A stored copy, named by the SHA-1 of its bytes. */ + | { kind: 'file'; file: string; binary: boolean } + +/** Whether a filesystem error names a missing path. */ +function isMissing(error: unknown): boolean { + return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'ENOENT' +} + +/** + * Store the current content of one path. + * @param absolute - canonical absolute path of the file. + * @param directory - directory holding content-addressed copies; created when missing. + * @param maxBytes - inclusive byte cap on a stored copy. + * @returns the capture, or undefined for a path that is neither absent nor a regular file. + */ +export async function captureFile(absolute: string, directory: string, maxBytes: number): Promise { + let size: number + try { + const info = await stat(absolute) + if (!info.isFile()) return undefined + size = info.size + } catch (error: unknown) { + if (isMissing(error)) return { kind: 'absent' } + throw error + } + if (size > maxBytes) return { kind: 'oversized' } + const bytes = await readFile(absolute) + if (bytes.byteLength > maxBytes) return { kind: 'oversized' } + const file = join(directory, createHash('sha1').update(bytes).digest('hex')) + await mkdir(directory, { recursive: true }) + // Identical content across paths and turns shares one copy; `wx` keeps an existing copy as is. + await writeFile(file, bytes, { flag: 'wx' }).catch((error: unknown) => { + if ((error as { code?: unknown }).code !== 'EEXIST') throw error + }) + return { kind: 'file', file, binary: bytes.subarray(0, BINARY_PROBE_BYTES).includes(0) } +} + +/** Whether two captures hold the same content. */ +export function sameCapture(a: Capture, b: Capture): boolean { + return a.kind === b.kind && (a.kind !== 'file' || b.kind !== 'file' || a.file === b.file) +} + +/** A non-blank string, or undefined. */ +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined +} + +/** + * The path a first-party file-tool call is about to mutate: `write`, `edit`, + * and the mutating `str_replace_editor` commands. Other tools, reads, and + * incomplete arguments yield undefined. + * @param name - wire tool name. + * @param args - parsed call arguments. + * @returns the model-facing path, or undefined. + */ +export function mutationPath(name: string, args: unknown): string | undefined { + if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined + const record = args as Record + switch (name) { + case 'write': + return typeof record.content === 'string' ? text(record.file_path) : undefined + case 'edit': + return typeof record.old_string === 'string' && typeof record.new_string === 'string' ? text(record.file_path) : undefined + case 'str_replace_editor': + return record.command === 'create' || record.command === 'str_replace' || record.command === 'insert' ? text(record.path) : undefined + default: + return undefined + } +} diff --git a/packages/deliverables/workspace-changes/src/compare.ts b/packages/deliverables/workspace-changes/src/compare.ts new file mode 100644 index 0000000000..8600e2f01d --- /dev/null +++ b/packages/deliverables/workspace-changes/src/compare.ts @@ -0,0 +1,66 @@ +/** Line comparison of two whole-file texts, bounded by a timeout that degrades to whole-file replacement. */ +import { structuredPatch } from 'diff' +import type { WorkspaceDiffHunk } from './types.ts' + +/** Context lines around each change, the unified-diff default. */ +const CONTEXT_LINES = 3 + +/** Hunks, whether the timeout degraded them, and the changed-line totals they carry. */ +export interface Comparison { + hunks: WorkspaceDiffHunk[] + coarse: boolean + added: number + deleted: number +} + +/** + * A side's text with every line terminated, so the last line compares by + * content alone and empty text reads as no lines rather than one empty line. + */ +function terminated(text: string): string { + return text === '' || text.endsWith('\n') ? text : `${text}\n` +} + +/** Content lines of a terminated text; empty text is zero lines. */ +function lines(text: string): string[] { + return text === '' ? [] : text.slice(0, -1).split('\n') +} + +/** + * Compare two texts line by line. A side that is null means the file did not + * exist. A comparison exceeding `timeoutMs` yields one hunk that deletes every + * old line and adds every new line. + * @param before - turn-start text, or null. + * @param after - turn-end text, or null. + * @param timeoutMs - milliseconds the line comparison may run. + * @returns hunks and totals; no hunks when both sides hold the same lines. + */ +export function compareText(before: string | null, after: string | null, timeoutMs: number): Comparison { + const oldText = terminated(before ?? '') + const newText = terminated(after ?? '') + const patch = structuredPatch('', '', oldText, newText, undefined, undefined, { context: CONTEXT_LINES, timeout: timeoutMs }) + let hunks: WorkspaceDiffHunk[] + let coarse = false + if (patch === undefined) { + coarse = true + const oldLines = lines(oldText) + const newLines = lines(newText) + hunks = [{ + oldStart: 1, oldLines: oldLines.length, + newStart: 1, newLines: newLines.length, + lines: [...oldLines.map(line => `-${line}`), ...newLines.map(line => `+${line}`)], + }] + } else { + hunks = patch.hunks.map(({ oldStart, oldLines, newStart, newLines, lines: body }) => + ({ oldStart, oldLines, newStart, newLines, lines: body })) + } + let added = 0 + let deleted = 0 + for (const hunk of hunks) { + for (const line of hunk.lines) { + if (line.startsWith('+')) added += 1 + else if (line.startsWith('-')) deleted += 1 + } + } + return { hunks, coarse, added, deleted } +} diff --git a/packages/deliverables/workspace-changes/src/git.ts b/packages/deliverables/workspace-changes/src/git.ts index 0722690a1c..c34812f673 100644 --- a/packages/deliverables/workspace-changes/src/git.ts +++ b/packages/deliverables/workspace-changes/src/git.ts @@ -24,6 +24,8 @@ export interface GitRunOptions { cwd: string env?: Readonly> | undefined stdin?: string | undefined + /** In-memory stdout cap for this command, replacing the runner's `outputMaxBytes`. */ + maxBytes?: number | undefined signal: AbortSignal } @@ -58,7 +60,7 @@ export class GitRunner { cwd: options.cwd, stdio: { stdin: options.stdin === undefined ? 'ignore' : { data: options.stdin }, - stdout: { maxBytes: this.limits.outputMaxBytes }, + stdout: { maxBytes: options.maxBytes ?? this.limits.outputMaxBytes }, stderr: { maxBytes: STDERR_TAIL_BYTES }, }, graceMs: TERMINATE_GRACE_MS, @@ -168,6 +170,49 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign } } +/** A blob one snapshot tree holds at a path. */ +export interface TreeBlob { + oid: string + /** Object size in bytes. */ + size: number +} + +/** + * The blob a snapshot tree holds at one path. + * @param git - command runner. + * @param workspace - addressed repository. + * @param tree - snapshot tree id. + * @param path - slash-separated path relative to the repository root. + * @param signal - cancellation. + * @returns the blob, or null when the tree holds nothing at the path or holds a gitlink or tree there. + */ +export async function treeBlob( + git: GitRunner, workspace: GitWorkspace, tree: string, path: string, signal: AbortSignal, +): Promise { + const result = ok(await git.run(['ls-tree', '-z', '-l', tree, '--', path], { cwd: workspace.root, env: workspace.env, signal }), 'git ls-tree') + const entry = result.stdout.split('\0')[0] ?? '' + const match = /^\d+ (\S+) ([0-9a-f]+) +(\d+)\t/.exec(entry) + if (match === null || match[1] !== 'blob') return null + return { oid: match[2] as string, size: Number(match[3]) } +} + +/** + * The text of one blob. + * @param git - command runner. + * @param workspace - addressed repository. + * @param oid - blob id. + * @param maxBytes - inclusive byte cap; a larger blob throws. + * @param signal - cancellation. + * @returns the blob decoded as UTF-8. + */ +export async function blobText( + git: GitRunner, workspace: GitWorkspace, oid: string, maxBytes: number, signal: AbortSignal, +): Promise { + const result = ok(await git.run(['cat-file', 'blob', oid], { cwd: workspace.root, env: workspace.env, maxBytes, signal }), 'git cat-file') + if (result.truncated) throw new Error(`blob ${oid} exceeds ${maxBytes} bytes`) + return result.stdout +} + /** * Per-file line counts between two snapshot trees, with renames detected. * @param git - command runner. diff --git a/packages/deliverables/workspace-changes/src/index.ts b/packages/deliverables/workspace-changes/src/index.ts index 23a88e7f74..b29d0a355b 100644 --- a/packages/deliverables/workspace-changes/src/index.ts +++ b/packages/deliverables/workspace-changes/src/index.ts @@ -1,10 +1,12 @@ /** * Summarizes the files each top-level turn changed from git working-tree - * snapshots taken at turn start and turn end plus the hunks file tools persist - * for paths git does not cover. Each summary is announced by a `workspace/changes` - * Session event that carries only the turn number and is served through the - * `workspaceChanges` service until the Session is disposed. Outside a git - * repository, or without git, the summary lists file-tool edits only. + * snapshots taken at turn start and turn end, plus whole-file captures taken + * around each file-tool edit for paths git does not cover, and serves each + * listed file's before-and-after comparison on demand. Each summary is + * announced by a `workspace/changes` Session event that carries only the turn + * number; summaries and comparisons are served through the `workspaceChanges` + * service until the Session is disposed. Outside a git repository, or without + * git, the summary lists file-tool edits only. */ import { homedir, tmpdir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' @@ -17,7 +19,9 @@ import { GitRunner } from './git.ts' import { TurnRecorder } from './recorder.ts' import type { WorkspaceChanges } from './types.ts' -export type { WorkspaceChangedFile, WorkspaceChanges, WorkspaceChangesSummary } from './types.ts' +export type { + WorkspaceChangedFile, WorkspaceChanges, WorkspaceChangesSummary, WorkspaceDiffHunk, WorkspaceFileDiff, +} from './types.ts' /** Stable Loader identity. */ export const name = 'workspace-changes' @@ -25,7 +29,7 @@ export const name = 'workspace-changes' /** Services used to run git and observe turns. */ export const inject = ['subprocess'] -/** Snapshot bounds. Invalid values fail plugin load. */ +/** Snapshot, capture, and comparison bounds. Invalid values fail plugin load. */ export interface Config { /** Milliseconds one git command may run before the turn's record is abandoned. */ timeoutMs: number @@ -33,6 +37,13 @@ export interface Config { outputMaxBytes: number /** Maximum files carried by one summary; `total` still reports the complete count. */ maxFiles: number + /** + * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; + * a larger file is listed without counts or comparison. + */ + maxFileBytes: number + /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */ + diffTimeoutMs: number } /** Schemastery validation for {@link Config}. */ @@ -40,6 +51,8 @@ export const Config: z = z.object({ timeoutMs: z.number().default(30_000), outputMaxBytes: z.number().default(8 * 1024 * 1024), maxFiles: z.number().default(500), + maxFileBytes: z.number().default(2 * 1024 * 1024), + diffTimeoutMs: z.number().default(100), }) function eligible(session: Session): string | undefined { @@ -72,15 +85,16 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise byId.get(sessionId)?.summary(seq) } + const service: WorkspaceChanges = { + summary: (sessionId, seq) => byId.get(sessionId)?.summary(seq), + diff: (sessionId, seq, index, signal) => byId.get(sessionId)?.diff(seq, index, signal) ?? Promise.resolve(undefined), + } ctx.provide('workspaceChanges', service) let runner: Promise | undefined const gitRunner = (): Promise => { @@ -115,6 +132,7 @@ export function apply(ctx: Context, config: Config): void { if (recorder === undefined) { recorder = new TurnRecorder(session, cwd, { git: gitRunner(), tempRoot: tmpdir(), maxFiles: config.maxFiles, + maxFileBytes: config.maxFileBytes, diffTimeoutMs: config.diffTimeoutMs, warn: (message) => { ctx.logger.warn(message) }, }) recorders.set(session, recorder) @@ -128,8 +146,7 @@ export function apply(ctx: Context, config: Config): void { if (cwd !== undefined) recorderFor(session, cwd).start(event.data.turn) return } - if (event.type === 'tool/call') recorders.get(session)?.observeCall(event) - else if (event.type === 'tool/result') recorders.get(session)?.observe(event) + if (event.type === 'tool/result') recorders.get(session)?.observe(event) else if (event.type === 'turn/end') recorders.get(session)?.end(event.data.turn) }) ctx.on('session/disposed', (session) => { void forget(session) }) @@ -138,7 +155,11 @@ export function apply(ctx: Context, config: Config): void { }) ctx.on('tools/pre-execute', async (exec, next) => { const session = exec.agent?.session - if (session !== undefined) await recorders.get(session)?.settled() + const recorder = session === undefined ? undefined : recorders.get(session) + if (recorder !== undefined) { + recorder.capture(exec.name, exec.arguments) + await recorder.settled() + } return next() }) } diff --git a/packages/deliverables/workspace-changes/src/numstat.ts b/packages/deliverables/workspace-changes/src/numstat.ts index 79be1c504a..7021a810aa 100644 --- a/packages/deliverables/workspace-changes/src/numstat.ts +++ b/packages/deliverables/workspace-changes/src/numstat.ts @@ -1,10 +1,10 @@ -/** Line-count derivation from `git diff-tree --numstat -z` output and from recorded file-tool hunks. */ -import { structuredPatch } from 'diff' -import type { FileDiff } from '@deepseek-ai/dsh-tools' +/** Parsing of `git diff-tree --numstat -z` output. */ -/** One `--numstat` record; `path` is slash-separated and relative to the repository root. */ +/** One `--numstat` record; paths are slash-separated and relative to the repository root. */ export interface NumstatEntry { path: string + /** The path before a detected rename; absent for a file that kept its path. */ + oldPath?: string added: number deleted: number binary: boolean @@ -12,7 +12,7 @@ export interface NumstatEntry { /** * Parse NUL-terminated numstat records. A rename record carries an empty path - * followed by the old and new paths; only the new path is kept. + * followed by the old and new paths. * @param output - complete stdout of `git diff-tree -r -M -z --numstat`. * @returns records in git's output order. * @throws when a record is malformed, which indicates truncated output. @@ -32,120 +32,21 @@ export function parseNumstat(output: string): NumstatEntry[] { const deleted = record.slice(first + 1, second) const path = record.slice(second + 1) let target = path + let oldPath: string | undefined if (target === '') { - queue.shift() + oldPath = queue.shift() const renamed = queue.shift() - if (renamed === undefined) throw new Error('malformed numstat rename record') + if (oldPath === undefined || renamed === undefined) throw new Error('malformed numstat rename record') target = renamed } const binary = added === '-' - entries.push({ path: target, added: binary ? 0 : Number(added), deleted: binary ? 0 : Number(deleted), binary }) + entries.push({ + path: target, + ...oldPath === undefined ? {} : { oldPath }, + added: binary ? 0 : Number(added), + deleted: binary ? 0 : Number(deleted), + binary, + }) } return entries } - -/** - * A side's text with every line terminated, so the last line compares by - * content alone: the file tools persist hunk sides without a trailing newline, - * and the same rule reads empty text as no lines rather than one empty line. - */ -function terminated(text: string): string { - return text === '' || text.endsWith('\n') ? text : `${text}\n` -} - -/** - * Sum the added and deleted lines over recorded hunks. Context lines appear on - * both sides of a hunk and cancel out; a hunk without prior text counts every - * line as added; a trailing newline never counts as a changed line. - * @param diffs - the applied hunks a file tool persisted with its result. - * @returns line totals for one file. - */ -export function hunkLineCounts(diffs: readonly FileDiff[]): { added: number; deleted: number } { - let added = 0 - let deleted = 0 - for (const diff of diffs) { - const patch = structuredPatch('', '', terminated(diff.oldText ?? ''), terminated(diff.newText)) - for (const hunk of patch.hunks) { - for (const line of hunk.lines) { - if (line.startsWith('+')) added += 1 - else if (line.startsWith('-')) deleted += 1 - } - } - } - return { added, deleted } -} - -/** - * Narrow a tool result's opaque `meta` to the file-tool hunk list. - * @param meta - persisted result metadata. - * @returns the hunks; an empty list for a `write` that updated a file without changing it; undefined when the - * metadata carries none, as `write` persists for a created file, so the call's arguments supply the content. - */ -export function fileDiffsOf(meta: unknown): FileDiff[] | undefined { - if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined - const { diffs, operation } = meta as Record - if (!Array.isArray(diffs)) return undefined - if (diffs.length === 0) return operation === 'update' ? [] : undefined - const out: FileDiff[] = [] - for (const value of diffs) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined - const { path, oldText, newText } = value as Record - if (typeof path !== 'string' || (oldText !== null && typeof oldText !== 'string') || typeof newText !== 'string') return undefined - out.push({ path, oldText, newText }) - } - return out -} - -/** Non-blank string argument, or undefined. */ -function text(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() !== '' ? value : undefined -} - -/** - * Hunks a first-party mutation call implies from its own arguments, for - * results that persist no hunks: `write` creates, and every - * `str_replace_editor` mutation. Malformed or non-mutating calls yield null. - * @param name - wire tool name. - * @param argumentsRaw - model-produced JSON arguments. - * @returns the implied hunks, or null. - */ -export function argumentHunks(name: string, argumentsRaw: string): FileDiff[] | null { - let args: unknown - try { - args = JSON.parse(argumentsRaw) as unknown - } catch { - return null - } - if (typeof args !== 'object' || args === null || Array.isArray(args)) return null - const record = args as Record - switch (name) { - case 'write': { - const path = text(record.file_path) - return path !== undefined && typeof record.content === 'string' ? [{ path, oldText: null, newText: record.content }] : null - } - case 'edit': { - const path = text(record.file_path) - return path !== undefined && typeof record.old_string === 'string' && record.old_string !== '' && typeof record.new_string === 'string' - ? [{ path, oldText: record.old_string, newText: record.new_string }] - : null - } - case 'str_replace_editor': { - const path = text(record.path) - if (path === undefined) return null - switch (record.command) { - case 'create': - return typeof record.file_text === 'string' ? [{ path, oldText: null, newText: record.file_text }] : null - case 'str_replace': - return typeof record.old_str === 'string' && record.old_str !== '' && (record.new_str === undefined || typeof record.new_str === 'string') - ? [{ path, oldText: record.old_str, newText: record.new_str ?? '' }] - : null - case 'insert': - return typeof record.new_str === 'string' ? [{ path, oldText: null, newText: record.new_str }] : null - default: - return null - } - } - default: - return null - } -} diff --git a/packages/deliverables/workspace-changes/src/recorder.ts b/packages/deliverables/workspace-changes/src/recorder.ts index f62630c595..53783b5673 100644 --- a/packages/deliverables/workspace-changes/src/recorder.ts +++ b/packages/deliverables/workspace-changes/src/recorder.ts @@ -1,22 +1,28 @@ -/** Per-Session turn recorder: snapshot at turn start, diff at turn end, summary kept until disposal. */ -import { mkdtemp, realpath, rm } from 'node:fs/promises' +/** Per-Session turn recorder: snapshots, captures around file-tool edits, the turn-end diff, and the records kept until disposal. */ +import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' import { homedir } from 'node:os' import { join, relative, resolve } from 'node:path' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { diffTrees, gitlinkPaths, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts' -import { argumentHunks, fileDiffsOf, hunkLineCounts } from './numstat.ts' +import { captureFile, mutationPath, sameCapture, type Capture } from './capture.ts' +import { compareText } from './compare.ts' +import { + blobText, diffTrees, gitlinkPaths, ignoredPaths, locateGitWorkspace, snapshotTree, treeBlob, type GitRunner, type GitWorkspace, +} from './git.ts' import { canonicalPath, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, temporaryRoots, toPosix } from './paths.ts' -import type { WorkspaceChangedFile, WorkspaceChangesSummary } from './types.ts' +import type { WorkspaceChangedFile, WorkspaceChangesSummary, WorkspaceFileDiff } from './types.ts' /** Facts shared by every recorder of one plugin instance. */ export interface RecorderEnvironment { - /** Resolves to the runner, or null when git is unavailable and no turn records anything. */ + /** Resolves to the runner, or null when git is unavailable and no snapshot is taken. */ git: Promise - /** Directory that receives each Session's snapshot object directory. */ + /** Directory that receives each Session's temporary directory. */ tempRoot: string /** Maximum files carried by one summary. */ maxFiles: number + /** Inclusive byte cap on a captured copy and on a snapshot blob read for a comparison. */ + maxFileBytes: number + /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */ + diffTimeoutMs: number /** Failure reporter; a failed turn records nothing and the next turn retries. */ warn: (message: string) => void } @@ -40,18 +46,35 @@ interface Repository { /** The turn-start snapshot of a located repository. */ interface Baseline extends Repository { tree: string } +/** Where one side of a listed file's content lives. */ +type ContentSource = + /** A path in a snapshot tree; absence, size, and text are read from git when asked for. */ + | { kind: 'snapshot'; repository: Repository; tree: string; path: string } + | Capture + +/** The two sides of one listed file, kept beside the served summary. */ +interface FileSources { + before: ContentSource + after: ContentSource + binary: boolean +} + +/** A served summary with the content sources of its listed files, index-aligned with `summary.files`. */ +interface TurnRecord { + summary: WorkspaceChangesSummary + sources: FileSources[] +} + /** Everything one turn accumulates; a new turn gets a new object so queued work for an older turn keeps its own. */ interface TurnState { readonly turn: number /** * The turn-start snapshot once it exists. `null` means no repository or no git, so the turn summarizes file-tool - * hunks only; `'failed'` means the repository exists but its snapshot failed, so the turn records nothing. + * captures only; `'failed'` means the repository exists but its snapshot failed, so the turn records nothing. */ baseline: Baseline | null | 'failed' - /** Hunks derived from each mutation call's arguments, or null for a call that changes no file. */ - readonly calls: Map - /** File-tool hunks by their model-facing path; canonicalized when the record is built. */ - readonly hunks: Map + /** Content of each file-tool-mutated path before the turn's first mutation of it, by canonical absolute path. */ + readonly captures: Map lastToolResultSeq: number /** Log length when the latest record attempt started; `end()` skips a turn already attempted after its last tool result. */ attemptedAfterSeq: number @@ -60,17 +83,25 @@ interface TurnState { } function freshState(turn: number): TurnState { - return { turn, baseline: null, calls: new Map(), hunks: new Map(), lastToolResultSeq: -1, attemptedAfterSeq: -1, recordedAfterSeq: -1 } + return { turn, baseline: null, captures: new Map(), lastToolResultSeq: -1, attemptedAfterSeq: -1, recordedAfterSeq: -1 } } +/** A listed file with the sources of its two sides. */ +interface Listed { file: WorkspaceChangedFile; sources: FileSources } + +/** A read side larger than the byte cap. */ +const OVERSIZED = Symbol('oversized') + /** - * Serializes one Session's git work: the turn-start snapshot, the turn-end - * snapshot with its diff, and the appended `workspace/changes` event whose - * summary this recorder keeps. Snapshot objects live in a temporary directory - * owned by the recorder; disposal removes it together with the summaries. - * Tool execution waits for pending work so a snapshot never races a mutation. - * A working directory outside any repository, or a Host without git, gets no - * snapshot; its summary lists the files the file tools changed. + * Serializes one Session's recording work: the turn-start snapshot, the + * whole-file capture before each file-tool mutation, the turn-end snapshot + * with its diff, and the appended `workspace/changes` event whose summary and + * comparisons this recorder keeps. Snapshot objects and captured copies live in + * a temporary directory owned by the recorder; disposal removes it together + * with the summaries. Tool execution waits for pending work so a snapshot or + * capture never races a mutation. A working directory outside any repository, + * or a Host without git, gets no snapshot; its summary lists the files the file + * tools changed. */ export class TurnRecorder { private chain: Promise = Promise.resolve() @@ -80,10 +111,10 @@ export class TurnRecorder { private paths: Paths | undefined /** The located repository, reused across turns once found; null keeps retrying each turn. */ private repository: Repository | null = null - /** Temporary directory holding this Session's snapshot objects and scratch indexes, created with the first located repository. */ - private scratch: string | undefined - /** Summaries by the sequence of the event that announced them. */ - private readonly summaries = new Map() + /** Temporary directory holding this Session's snapshot objects, scratch indexes, and captured copies. */ + private scratch: Promise | undefined + /** Records by the sequence of the event that announced them. */ + private readonly records = new Map() private readonly lifetime = new AbortController() constructor( @@ -115,30 +146,33 @@ export class TurnRecorder { } /** - * Remember a mutation call's arguments so a result without persisted hunks can still count its lines. - * @param event - the appended `tool/call` event. + * Queue the capture of the path a file tool is about to mutate, before the + * tool runs; only the turn's first mutation of a path captures it. Await + * {@link settled} afterwards so the tool cannot overtake the capture. + * @param name - wire tool name. + * @param args - parsed call arguments. */ - observeCall(event: SessionEvent<'tool/call'>): void { + capture(name: string, args: unknown): void { + const path = mutationPath(name, args) + if (path === undefined) return const state = this.state - if (event.data.turn !== state.turn) return - state.calls.set(String(event.data.callId), argumentHunks(event.data.name, event.data.arguments)) + void this.enqueue(async () => { + const paths = this.paths + if (paths === undefined) return + const absolute = await canonicalPath(resolve(paths.cwd, path)) + if (state.captures.has(absolute)) return + const capture = await captureFile(absolute, join(await this.scratchDir(), 'captures'), this.env.maxFileBytes) + if (capture !== undefined) state.captures.set(absolute, capture) + }) } /** - * Remember a settled tool result and the hunks it carries, falling back to the call's arguments. + * Remember a settled tool result, so a record after `turn/end` covers it. * @param event - the appended `tool/result` event. */ observe(event: SessionEvent<'tool/result'>): void { const state = this.state - if (event.data.turn !== state.turn) return - state.lastToolResultSeq = event.seq - if (event.data.message.content[0].isError === true) return - const diffs = fileDiffsOf(event.data.meta) ?? state.calls.get(String(event.data.message.source.callId)) ?? [] - for (const diff of diffs) { - const list = state.hunks.get(diff.path) - if (list === undefined) state.hunks.set(diff.path, [diff]) - else list.push(diff) - } + if (event.data.turn === state.turn) state.lastToolResultSeq = event.seq } /** @@ -162,7 +196,7 @@ export class TurnRecorder { void this.enqueue(signal => this.record(state, signal)) } - /** Resolves once every queued snapshot and record has settled. */ + /** Resolves once every queued snapshot, capture, and record has settled. */ settled(): Promise { return this.chain } @@ -173,18 +207,47 @@ export class TurnRecorder { * @returns the summary, or undefined for a sequence this recorder did not announce. */ summary(seq: number): WorkspaceChangesSummary | undefined { - return this.summaries.get(seq) + return this.records.get(seq)?.summary } /** - * Abort queued git work, forget every summary, and remove the snapshot objects. + * Compare one listed file's contents at turn start and turn end. + * @param seq - the announcing event's sequence number. + * @param index - the file's index in the summary's `files`. + * @param signal - cancels the reads. + * @returns the comparison, or undefined for an unknown sequence or index, or once disposed. + * @throws when a read fails while the recorder lives. + */ + async diff(seq: number, index: number, signal: AbortSignal): Promise { + const record = this.records.get(seq) + const file = record?.summary.files[index] + const sources = record?.sources[index] + if (file === undefined || sources === undefined) return undefined + const { path, display } = file + if (sources.binary) return { kind: 'binary', path, display } + if (file.oversized === true) return { kind: 'oversized', path, display } + const combined = AbortSignal.any([signal, this.lifetime.signal]) + try { + const [before, after] = await Promise.all([this.readSide(sources.before, combined), this.readSide(sources.after, combined)]) + if (before === OVERSIZED || after === OVERSIZED) return { kind: 'oversized', path, display } + const { hunks, coarse } = compareText(before, after, this.env.diffTimeoutMs) + return { kind: 'text', path, display, before: before !== null, after: after !== null, hunks, coarse } + } catch (error: unknown) { + // Disposal removes the temporary directory under a running read; the Session is gone either way. + if (this.lifetime.signal.aborted) return undefined + throw error + } + } + + /** + * Abort queued work, forget every record, and remove the temporary directory. * @returns once the temporary directory is gone. */ async dispose(): Promise { this.lifetime.abort() - this.summaries.clear() + this.records.clear() await this.chain - if (this.scratch !== undefined) await rm(this.scratch, { recursive: true, force: true }) + if (this.scratch !== undefined) await rm(await this.scratch, { recursive: true, force: true }) } private enqueue(task: (signal: AbortSignal) => Promise): Promise { @@ -205,20 +268,39 @@ export class TurnRecorder { if (!this.lifetime.signal.aborted) this.env.warn(`workspace-changes: ${String(error)}`) } + /** This Session's temporary directory, created on first use. */ + private scratchDir(): Promise { + this.scratch ??= mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-')) + return this.scratch + } + /** The repository enclosing the working directory, located once; null keeps retrying each turn. */ private async locate(cwd: string, signal: AbortSignal): Promise { if (this.repository !== null) return this.repository const git = await this.env.git if (git === null) return null - const workspace = await locateGitWorkspace(git, cwd, async () => { - this.scratch ??= await mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-')) - return this.scratch - }, signal) + const workspace = await locateGitWorkspace(git, cwd, () => this.scratchDir(), signal) if (workspace === null) return null this.repository = { git, workspace } return this.repository } + /** One side's text, null for an absent file, or {@link OVERSIZED} for a side beyond the byte cap. */ + private async readSide(source: ContentSource, signal: AbortSignal): Promise { + switch (source.kind) { + case 'absent': return null + case 'oversized': return OVERSIZED + case 'file': return readFile(source.file, 'utf8') + case 'snapshot': { + const { git, workspace } = source.repository + const blob = await treeBlob(git, workspace, source.tree, source.path, signal) + if (blob === null) return null + if (blob.size > this.env.maxFileBytes) return OVERSIZED + return blobText(git, workspace, blob.oid, this.env.maxFileBytes, signal) + } + } + } + private async record(state: TurnState, signal: AbortSignal): Promise { const paths = this.paths const { baseline } = state @@ -226,24 +308,28 @@ export class TurnRecorder { state.attemptedAfterSeq = state.lastToolResultSeq // Without a snapshot the working directory itself bounds the workspace. const root = baseline?.workspace.root ?? paths.cwd - const files = new Map() + const listed = new Map() let snapshot: WorkspaceChangesSummary['snapshot'] if (baseline !== null) { const after = await snapshotTree(baseline.git, baseline.workspace, signal) snapshot = { before: baseline.tree, after } + const repository: Repository = { git: baseline.git, workspace: baseline.workspace } for (const entry of await diffTrees(baseline.git, baseline.workspace, baseline.tree, after, signal)) { const absolute = resolve(root, entry.path) - files.set(absolute, changedFile(paths, root, absolute, entry)) + listed.set(absolute, { + file: changedFile(paths, root, absolute, entry), + sources: { + before: { kind: 'snapshot', repository, tree: baseline.tree, path: entry.oldPath ?? entry.path }, + after: { kind: 'snapshot', repository, tree: after, path: entry.path }, + binary: entry.binary, + }, + }) } } - // File-tool hunks by canonical absolute path, for the files snapshots do not cover. - const hunks = new Map() - for (const [path, list] of state.hunks) { - const absolute = await canonicalPath(resolve(paths.cwd, path)) - if (!files.has(absolute)) hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list]) - } + // Captured paths the snapshots do not cover are compared from their copies. + const captured = [...state.captures.keys()].filter(absolute => !listed.has(absolute)) const workTreePath = (absolute: string): string => toPosix(relative(root, absolute)) - let inWorkspace = [...hunks.keys()].filter(absolute => isInside(root, absolute)) + let inWorkspace = captured.filter(absolute => isInside(root, absolute)) if (baseline !== null && inWorkspace.length > 0) { // Nested repositories and submodules are gitlinks: their contents never enter the summary. const gitlinks = await gitlinkPaths(baseline.git, baseline.workspace, signal) @@ -253,38 +339,64 @@ export class TurnRecorder { const uncoveredInWorkspace = baseline === null ? new Set(inWorkspace.map(workTreePath)) : await ignoredPaths(baseline.git, baseline.workspace, inWorkspace.map(workTreePath), signal) - for (const [absolute, list] of hunks) { + for (const absolute of captured) { // Outside the workspace, scratch files under a temporary root stay out. const uncovered = isInside(root, absolute) ? uncoveredInWorkspace.has(workTreePath(absolute)) : !isTemporaryPath(absolute, paths.temporaryRoots) - if (uncovered) files.set(absolute, changedFile(paths, root, absolute, { ...hunkLineCounts(list), binary: false })) + if (!uncovered) continue + const before = state.captures.get(absolute) as Capture + const after = await captureFile(absolute, join(await this.scratchDir(), 'captures'), this.env.maxFileBytes) + if (after === undefined || sameCapture(before, after)) continue + listed.set(absolute, { + file: changedFile(paths, root, absolute, await this.capturedCounts(before, after)), + sources: { before, after, binary: isBinary(before) || isBinary(after) }, + }) } - const sorted = [...files.values()].sort(compareDisplay) + const sorted = [...listed.values()].sort((a, b) => compareDisplay(a.file, b.file)) // An empty list after an earlier in-turn record supersedes that record. if (sorted.length === 0 && state.recordedAfterSeq < 0) return const event = this.session.append('workspace/changes', { turn: state.turn }) - this.summaries.set(event.seq, { - turn: state.turn, - cwd: this.cwd, - files: sorted.slice(0, this.env.maxFiles), - total: sorted.length, - added: sorted.reduce((sum, file) => sum + file.added, 0), - deleted: sorted.reduce((sum, file) => sum + file.deleted, 0), - ...snapshot === undefined ? {} : { snapshot }, + const kept = sorted.slice(0, this.env.maxFiles) + this.records.set(event.seq, { + summary: { + turn: state.turn, + cwd: this.cwd, + files: kept.map(entry => entry.file), + total: sorted.length, + added: sorted.reduce((sum, entry) => sum + entry.file.added, 0), + deleted: sorted.reduce((sum, entry) => sum + entry.file.deleted, 0), + ...snapshot === undefined ? {} : { snapshot }, + }, + sources: kept.map(entry => entry.sources), }) state.recordedAfterSeq = event.seq } + + /** Line counts of a captured pair; none for a binary or oversized side. */ + private async capturedCounts(before: Capture, after: Capture): Promise { + if (before.kind === 'oversized' || after.kind === 'oversized') return { added: 0, deleted: 0, binary: false, oversized: true } + if (isBinary(before) || isBinary(after)) return { added: 0, deleted: 0, binary: true } + const text = async (side: Capture): Promise => side.kind === 'file' ? readFile(side.file, 'utf8') : null + const { added, deleted } = compareText(await text(before), await text(after), this.env.diffTimeoutMs) + return { added, deleted, binary: false } + } } -function changedFile( - { cwd, home }: Paths, root: string, absolute: string, counts: { added: number; deleted: number; binary: boolean }, -): WorkspaceChangedFile { +/** Whether a captured side holds binary content. */ +function isBinary(capture: Capture): boolean { + return capture.kind === 'file' && capture.binary +} + +interface Counts { added: number; deleted: number; binary: boolean; oversized?: boolean } + +function changedFile({ cwd, home }: Paths, root: string, absolute: string, counts: Counts): WorkspaceChangedFile { return { path: durablePathOf(absolute, cwd), display: displayPathOf(absolute, cwd, root, home), added: counts.added, deleted: counts.deleted, ...counts.binary ? { binary: true as const } : {}, + ...counts.oversized === true ? { oversized: true as const } : {}, } } diff --git a/packages/deliverables/workspace-changes/src/types.ts b/packages/deliverables/workspace-changes/src/types.ts index a1b3762927..8e48381480 100644 --- a/packages/deliverables/workspace-changes/src/types.ts +++ b/packages/deliverables/workspace-changes/src/types.ts @@ -1,7 +1,7 @@ -/** Per-turn workspace change summaries, the Session event that announces them, and the Host service that serves them. */ +/** Per-turn workspace change summaries, the Session event announcing them, and the Host service serving them with their comparisons. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' -/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */ +/** One file changed during a turn, with line counts from git or from the whole-file captures around its file-tool edits. */ export interface WorkspaceChangedFile { /** Path relative to the Session working directory, or an absolute Host path outside it. */ path: string @@ -11,12 +11,14 @@ export interface WorkspaceChangedFile { * directory, otherwise the absolute path. Always slash-separated. */ display: string - /** Lines added; zero for a binary file. */ + /** Lines added; zero for a binary or oversized file. */ added: number - /** Lines deleted; zero for a binary file. */ + /** Lines deleted; zero for a binary or oversized file. */ deleted: number - /** Present when git reported the file as binary. */ + /** Present when git reported the file as binary, or when a captured side holds a NUL byte. */ binary?: true + /** Present when a captured side exceeded the plugin's `maxFileBytes`; the file is listed without counts or comparison. */ + oversized?: true } /** Files changed during one top-level turn, kept on the Host until its Session is disposed. */ @@ -37,7 +39,43 @@ export interface WorkspaceChangesSummary { snapshot?: { before: string; after: string } } -/** Serves the summaries the recorder keeps for live Sessions. */ +/** One unified-diff hunk with three context lines; every line keeps its `+`, `-`, or space prefix. */ +export interface WorkspaceDiffHunk { + /** First line of the hunk in the turn-start content, 1-based; a side without lines starts at 1 with zero lines. */ + oldStart: number + /** Lines of the hunk taken from the turn-start content. */ + oldLines: number + /** First line of the hunk in the turn-end content, 1-based; a side without lines starts at 1 with zero lines. */ + newStart: number + /** Lines of the hunk taken from the turn-end content. */ + newLines: number + /** Hunk body in order, each line prefixed with `+`, `-`, or a space. */ + lines: string[] +} + +/** The comparison of one listed file's turn-start and turn-end contents, computed when asked for. */ +export type WorkspaceFileDiff = + | { + kind: 'text' + /** The listed file's `path`. */ + path: string + /** The listed file's `display`. */ + display: string + /** Whether the file existed at turn start. */ + before: boolean + /** Whether the file existed at turn end. */ + after: boolean + /** Hunks in file order; empty when both sides hold the same lines. */ + hunks: WorkspaceDiffHunk[] + /** True when the line comparison exceeded the plugin's `diffTimeoutMs` and every line is shown as replaced. */ + coarse: boolean + } + /** A side git reported as binary or that holds a NUL byte; no lines are served. */ + | { kind: 'binary'; path: string; display: string } + /** A side larger than the plugin's `maxFileBytes`; no lines are served. */ + | { kind: 'oversized'; path: string; display: string } + +/** Serves the summaries and file comparisons the recorder keeps for live Sessions. */ export interface WorkspaceChanges { /** * The summary announced by one `workspace/changes` event. @@ -46,6 +84,16 @@ export interface WorkspaceChanges { * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it. */ summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined + /** + * Compare one listed file's contents at turn start and turn end. + * @param sessionId - the Session that appended the event. + * @param seq - the event's sequence number. + * @param index - the file's index in the summary's `files`. + * @param signal - cancels the reads. + * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index. + * @throws when a snapshot read fails for a live Session. + */ + diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise } declare module '@deepseek-ai/dsh-session/types' { @@ -61,7 +109,7 @@ declare module '@deepseek-ai/dsh-session/types' { declare module '@deepseek-ai/cordis' { interface Context { - /** Per-turn changed-file summaries of live Sessions. */ + /** Per-turn changed-file summaries and comparisons of live Sessions. */ workspaceChanges: WorkspaceChanges } } diff --git a/packages/deliverables/workspace-changes/tests/capture.spec.ts b/packages/deliverables/workspace-changes/tests/capture.spec.ts new file mode 100644 index 0000000000..961ffe57c0 --- /dev/null +++ b/packages/deliverables/workspace-changes/tests/capture.spec.ts @@ -0,0 +1,63 @@ +/** Whole-file captures and the mutation paths that trigger them. */ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { captureFile, mutationPath, sameCapture } from '../src/capture.ts' +import { scratchDir } from './support.ts' + +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + cleanups.length = 0 +}) + +describe('captureFile', () => { + it('stores text once by content, and classifies absent, oversized, binary, and non-regular paths', async () => { + const root = await scratchDir('dsh-capture-', cleanups) + const store = join(root, 'captures') + await writeFile(join(root, 'a.txt'), 'one\ntwo\n') + await writeFile(join(root, 'same.txt'), 'one\ntwo\n') + await writeFile(join(root, 'big.txt'), 'x'.repeat(17)) + await writeFile(join(root, 'bin.dat'), Uint8Array.of(65, 0, 66)) + await mkdir(join(root, 'dir')) + const a = await captureFile(join(root, 'a.txt'), store, 16) + const same = await captureFile(join(root, 'same.txt'), store, 16) + expect(a).toMatchObject({ kind: 'file', binary: false }) + expect(same).toEqual(a) + if (a?.kind !== 'file' || same === undefined) throw new Error('expected a stored copy') + expect(await readFile(a.file, 'utf8')).toBe('one\ntwo\n') + expect(await readdir(store)).toHaveLength(1) + expect(await captureFile(join(root, 'big.txt'), store, 16)).toEqual({ kind: 'oversized' }) + expect(await captureFile(join(root, 'missing.txt'), store, 16)).toEqual({ kind: 'absent' }) + expect(await captureFile(join(root, 'bin.dat'), store, 16)).toMatchObject({ kind: 'file', binary: true }) + expect(await captureFile(join(root, 'dir'), store, 16)).toBeUndefined() + // Content that grew past the cap between the size check and the read is still refused. + await writeFile(join(root, 'grow.txt'), 'y'.repeat(16)) + expect(await captureFile(join(root, 'grow.txt'), store, 16)).toMatchObject({ kind: 'file' }) + expect(sameCapture({ kind: 'absent' }, { kind: 'absent' })).toBe(true) + expect(sameCapture({ kind: 'absent' }, { kind: 'oversized' })).toBe(false) + expect(sameCapture(a, same)).toBe(true) + expect(sameCapture(a, { kind: 'file', file: 'elsewhere', binary: false })).toBe(false) + }) +}) + +describe('mutationPath', () => { + it('names the path of write, edit, and mutating editor calls', () => { + expect(mutationPath('write', { file_path: 'a.txt', content: 'x' })).toBe('a.txt') + expect(mutationPath('edit', { file_path: 'a.txt', old_string: 'x', new_string: 'y' })).toBe('a.txt') + expect(mutationPath('str_replace_editor', { command: 'create', path: 'b.txt', file_text: 'b' })).toBe('b.txt') + expect(mutationPath('str_replace_editor', { command: 'str_replace', path: 'b.txt', old_str: 'b' })).toBe('b.txt') + expect(mutationPath('str_replace_editor', { command: 'insert', path: 'b.txt', insert_line: 1, new_str: 'i' })).toBe('b.txt') + }) + + it('yields nothing for reads, unknown tools, and incomplete arguments', () => { + expect(mutationPath('write', undefined)).toBeUndefined() + expect(mutationPath('write', [])).toBeUndefined() + expect(mutationPath('read', { file_path: 'a.txt' })).toBeUndefined() + expect(mutationPath('write', { file_path: ' ', content: 'x' })).toBeUndefined() + expect(mutationPath('write', { file_path: 'a.txt' })).toBeUndefined() + expect(mutationPath('edit', { file_path: 'a.txt', old_string: 'x' })).toBeUndefined() + expect(mutationPath('str_replace_editor', { command: 'view', path: 'b.txt' })).toBeUndefined() + expect(mutationPath('str_replace_editor', { command: 'create', path: '' })).toBeUndefined() + }) +}) diff --git a/packages/deliverables/workspace-changes/tests/compare.spec.ts b/packages/deliverables/workspace-changes/tests/compare.spec.ts new file mode 100644 index 0000000000..15af6cef13 --- /dev/null +++ b/packages/deliverables/workspace-changes/tests/compare.spec.ts @@ -0,0 +1,39 @@ +/** Line comparison with its timeout degradation. */ +import { describe, expect, it } from 'vitest' +import { compareText } from '../src/compare.ts' + +describe('compareText', () => { + it('yields unified hunks with context and counts only changed lines', () => { + const before = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'].join('\n') + const after = ['a', 'b', 'c', 'D', 'e', 'f', 'g', 'h', 'i', 'j', 'k'].join('\n') + const result = compareText(before, after, 100) + expect(result.coarse).toBe(false) + expect(result).toMatchObject({ added: 2, deleted: 1 }) + // Changes whose context lines touch share one hunk. + expect(result.hunks).toEqual([ + { oldStart: 1, oldLines: 10, newStart: 1, newLines: 11, lines: [' a', ' b', ' c', '-d', '+D', ' e', ' f', ' g', ' h', ' i', ' j', '+k'] }, + ]) + const far = compareText(`${before}\n${'z\n'.repeat(10)}end`, `${after}\n${'z\n'.repeat(10)}END`, 100) + expect(far.hunks.map(hunk => [hunk.oldStart, hunk.oldLines, hunk.newStart, hunk.newLines])).toEqual([[1, 13, 1, 14], [18, 4, 19, 4]]) + }) + + it('treats a missing side as no lines and an unterminated last line by content alone', () => { + expect(compareText(null, 'x\ny\n', 100)).toMatchObject({ added: 2, deleted: 0, hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 2 }] }) + expect(compareText('x\n', null, 100)).toMatchObject({ added: 0, deleted: 1 }) + expect(compareText('last', 'last\nadded', 100)).toMatchObject({ added: 1, deleted: 0 }) + expect(compareText('same\n', 'same\n', 100)).toEqual({ hunks: [], coarse: false, added: 0, deleted: 0 }) + expect(compareText(null, null, 100)).toEqual({ hunks: [], coarse: false, added: 0, deleted: 0 }) + }) + + it('degrades to whole-file replacement once the timeout passes', () => { + const before = Array.from({ length: 4000 }, (_, i) => `old ${i}`).join('\n') + const after = Array.from({ length: 4000 }, (_, i) => `new ${i}`).join('\n') + const result = compareText(before, after, 1) + expect(result.coarse).toBe(true) + expect(result).toMatchObject({ added: 4000, deleted: 4000 }) + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0]).toMatchObject({ oldStart: 1, oldLines: 4000, newStart: 1, newLines: 4000 }) + expect(result.hunks[0]!.lines[0]).toBe('-old 0') + expect(result.hunks[0]!.lines.at(-1)).toBe('+new 3999') + }) +}) diff --git a/packages/deliverables/workspace-changes/tests/git.spec.ts b/packages/deliverables/workspace-changes/tests/git.spec.ts index 7d3656c162..71cce19d30 100644 --- a/packages/deliverables/workspace-changes/tests/git.spec.ts +++ b/packages/deliverables/workspace-changes/tests/git.spec.ts @@ -138,7 +138,7 @@ describe('TurnRecorder', () => { let release!: (runner: GitRunner | null) => void const gate = new Promise((resolve) => { release = resolve }) const tempRoot = await scratchDir('dsh-git-store-', cleanups) - const env = { git: gate, tempRoot, maxFiles: 10, warn: (m: string) => { warnings.push(m) } } + const env = { git: gate, tempRoot, maxFiles: 10, maxFileBytes: 1024, diffTimeoutMs: 100, warn: (m: string) => { warnings.push(m) } } const disposed = new TurnRecorder(session, cwd, env) disposed.start(1) await new Promise(resolve => setTimeout(resolve, 5)) @@ -161,7 +161,10 @@ describe('TurnRecorder', () => { it('removes its snapshot objects on disposal and never creates them outside a repository', async () => { const tempRoot = await scratchDir('dsh-git-store-', cleanups) const { ctx, git: runnerGit } = await runner() - const env = { git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, warn: (m: string) => { throw new Error(m) } } + const env = { + git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, maxFileBytes: 1024, diffTimeoutMs: 100, + warn: (m: string) => { throw new Error(m) }, + } const plain = new TurnRecorder(ctx.sessions.create(SessionId('plain'), { meta: { cwd: tempRoot } }), tempRoot, env) plain.start(1) await plain.settled() @@ -187,7 +190,10 @@ describe('TurnRecorder', () => { await mkdir(tempRoot) const { ctx, git: runnerGit } = await runner() const session = ctx.sessions.create(SessionId('tmp-in-tree'), { meta: { cwd } }) - const env = { git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, warn: (m: string) => { throw new Error(m) } } + const env = { + git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, maxFileBytes: 1024, diffTimeoutMs: 100, + warn: (m: string) => { throw new Error(m) }, + } const recorder = new TurnRecorder(session, cwd, env) startTurn(session, 1) recorder.start(1) diff --git a/packages/deliverables/workspace-changes/tests/numstat.spec.ts b/packages/deliverables/workspace-changes/tests/numstat.spec.ts index 164839657b465bc037a3b4b28b3b19f62b7962d2..6f5d5266770b61b73b4cb7939accbc44960e253f 100644 GIT binary patch delta 106 zcmZ3a{g_)oUrS5DuQa!~B(X%HAhD=;qPqRWNsk#BCRZ~a7tGH|2}mr-uu@P5vh+$S qN+uh!8gDLQ3Sj~&IKz@5fTAF#qyi`(0hH=z^#hYyY_*zPKm-7UCLyQ* literal 5538 zcmcIoO>f&c5WVMD%uP<~I3L|piY~C-U7%PL?P1$f6a|ApgB@_#u&s z)HrQ7FapIQXNEKHy&2~C#S8k-mW>vgwxVuwwI-o5+O+B`6}ghMtXie<^@3g;ALQk> zsx{qHCYxoQU&}ekyRBU6IpvxjXjNAwB{#X2jZXU2qF%S9RQesPHgj6#h5RdDt(uRk zx$*QKj{amnc*#*nACkAYSR6O?@>tnO7rIFf4txl+B;1nBX?93&ey95bLQJ!ymLh9t zTZmlE={i@U-p$PsYpFyjsg}#C&YEZ+;nb?pRJD5BYI;K#$*WYKrkaBg?0!zkQL6FD z%k^Tb*8Ig;s!vkQPgRjE^qqNB^432lmy0h|u4YNvp5P}rM1Hw(`&vwjZ`7rOfU zzg85p3$v7v_^r^Jb4r3hM3%|yoLW z(setwy+4-Qem1^6a(~GuemFe9Z(AAN)~X>@=?&vCw;M4XH6hN7tI7klcS`HX1s0){7 zoK-eGt!f*fk(f6zVYr-MrQ9~8#dSluYVu5)Oc&BD-=mfnoI}JaQhn_y6HzMFcT6ro zFV#DKdPJxY3`4*NW*ua~l8!L};v>iglCC=1A3=b- zK(EbF4Y}f`H8ix`2(<=hxLq_xTJyE4YCJC;A<7D~FR z3bZzfwhg1EWi#&@!&WPGx)wQ1t~pz5`ZWh+1BDCjj4w~=2_K1m< zHkgN^YBR?$ub_`WS6$JMxad?$Ei$2n8)R&s6KZE0sYJ_<40VYOJk2n~@eFoO7vt$2 z^5dG1h(ZNZ$ul-a!%i4Rt1^j(EHh*Ht^Dl&o=tAKuvOh_ZGTE1`}4LXcR2R`q1{sj zJ_?;z3Zk_H#Wa_~XL>pY@cIvy3NNPBjjUM#q~|x!NrvNAV(q$aczSIBvJ^#;IN4R2 z$`-P~#5NK`P!+?-h~Z%=;^5mY5H=RP8%ZxG>(l3)F*JQ-+}&gxf%4FLwr$xb zI_Vttu523EKb8Rq{TI8v!|{9FXu=!EVu7oxTH;o^lvg%`CbDH!mPl$S9WF_JBxbT) z@lb^|tjMEs>=#ca$!K}RUz?&Q!wME4;6ZFIhtG-(hX`1a{AjxNj;kvy=Xme@4Wi_Y zKU7s*jAYFq9AwUZf5j7;xxG8^A;x^~7>DA>J57)e9iMl(EHLu%N^{#b6X4uygqLz lIs5rg1INQWm?4AHZNw`O37*+`;{A!d???K_e!yFNz5%`X_M!j) diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index 2b676f1547..a617d12251 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/packages/deliverables/workspace-changes/tests/plugin.spec.ts @@ -1,14 +1,14 @@ -/** The plugin records each top-level turn's file changes from real git snapshots. */ -import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises' +/** The plugin records each top-level turn's file changes from real git snapshots and whole-file captures, and serves their comparisons. */ +import { mkdir, mkdtemp, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as WorkspaceChanges from '../src/index.ts' -import { changes, endTurn, git, scratchDir, settle, startTurn, toolCall } from './support.ts' +import { changes, endTurn, git, mutate, scratchDir, settle, startTurn, toolCall } from './support.ts' const cleanups: Array<() => Promise> = [] afterEach(async () => { @@ -17,6 +17,8 @@ afterEach(async () => { vi.restoreAllMocks() }) +const signal = new AbortController().signal + async function boot(config: Partial = {}) { const ctx = new Context() cleanups.push(() => ctx.fiber.dispose()) @@ -43,6 +45,13 @@ async function repository(): Promise { return cwd } +/** The sequence of the latest announcement for one session. */ +function announcedSeq(session: Session): number { + const event = session.snapshotEvents().filter(event => event.type === 'workspace/changes').at(-1) + if (event === undefined) throw new Error('no workspace/changes announcement') + return event.seq +} + describe('workspace-changes in a repository', () => { it('records the turn’s own changes and excludes the user’s prior uncommitted work', async () => { const cwd = await repository() @@ -54,26 +63,21 @@ describe('workspace-changes in a repository', () => { startTurn(session, 1) await settle(ctx, session) - await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n') - toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } }) + await mutate(ctx, session, 1, 'edit', { file_path: 'a.txt', old_string: 'l2', new_string: 'l2 model' }, + () => writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n'), { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } }) await mkdir(join(cwd, 'sub', 'dir'), { recursive: true }) await writeFile(join(cwd, 'sub', 'dir', 'c.txt'), 'c\n') await writeFile(join(cwd, 'new.txt'), 'n1\nn2\n') await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255)) toolCall(session, 1, 'bash', { command: 'printf > files' }) - await writeFile(join(cwd, '.env'), 'A=1\nB=2\n') - // A created file persists an empty hunk list; its arguments supply the content. - toolCall(session, 1, 'write', { file_path: '.env', content: 'A=1\n' }, { meta: { diffs: [] } }) - toolCall(session, 1, 'edit', { file_path: '.env', old_string: 'A=1', new_string: 'A=1\nB=2' }, { - meta: { diffs: [{ path: '.env', oldText: 'A=1\n', newText: 'A=1\nB=2\n' }] }, - }) - toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, { meta: { diffs: [] } }) - toolCall(session, 1, 'write', { file_path: 'failed.txt', content: 'x' }, { isError: true, meta: { diffs: [] } }) - // The editor persists no hunks at all; its create arguments count, and the file may already be gone. - toolCall(session, 1, 'str_replace_editor', { command: 'create', path: '.env.gone', file_text: 'x' }) - toolCall(session, 1, 'edit', { file_path: 'same.txt', old_string: 'same', new_string: 'same' }, { - meta: { diffs: [{ path: 'same.txt', oldText: 'same', newText: 'same' }] }, - }) + // An ignored file is captured before its first edit and read again at turn end; repeated edits count once. + await mutate(ctx, session, 1, 'write', { file_path: '.env', content: 'A=1\n' }, () => writeFile(join(cwd, '.env'), 'A=1\n')) + await mutate(ctx, session, 1, 'edit', { file_path: '.env', old_string: 'A=1', new_string: 'A=1\nB=2' }, () => writeFile(join(cwd, '.env'), 'A=1\nB=2\n')) + await mutate(ctx, session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, () => Promise.resolve()) + await mutate(ctx, session, 1, 'write', { file_path: 'failed.txt', content: 'x' }, () => Promise.resolve(), { isError: true }) + // A created ignored file that is gone again by turn end is not a change. + await mutate(ctx, session, 1, 'str_replace_editor', { command: 'create', path: '.env.gone', file_text: 'x' }, () => Promise.resolve()) + await mutate(ctx, session, 1, 'edit', { file_path: 'same.txt', old_string: 'same', new_string: 'same' }, () => Promise.resolve()) toolCall(session, 2, 'write', { file_path: 'other-turn' }, { meta: { diffs: [{ path: 'other.txt', oldText: null, newText: 'x' }] } }) endTurn(session, 1) await settle(ctx, session) @@ -82,12 +86,11 @@ describe('workspace-changes in a repository', () => { expect(events.map(event => event.data)).toEqual([{ turn: 1 }]) const [recorded, ...rest] = changes(ctx, session) expect(rest).toEqual([]) - expect(recorded).toMatchObject({ turn: 1, cwd, total: 6, added: 8, deleted: 1 }) + expect(recorded).toMatchObject({ turn: 1, cwd, total: 5, added: 7, deleted: 1 }) expect(recorded!.snapshot!.before).toMatch(/^[0-9a-f]{40,64}$/) expect(recorded!.snapshot!.after).toMatch(/^[0-9a-f]{40,64}$/) expect(recorded!.files).toEqual([ { path: '.env', display: '.env', added: 2, deleted: 0 }, - { path: '.env.gone', display: '.env.gone', added: 1, deleted: 0 }, { path: 'a.txt', display: 'a.txt', added: 2, deleted: 1 }, { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true }, { path: 'new.txt', display: 'new.txt', added: 2, deleted: 0 }, @@ -107,6 +110,51 @@ describe('workspace-changes in a repository', () => { expect(ctx.workspaceChanges.summary(session.id, seq)).toBeUndefined() }) + it('serves each listed file’s comparison from the snapshots or the captured copies', async () => { + const cwd = await repository() + await writeFile(join(cwd, 'long.txt'), `${'0123456789'.repeat(10)}\n`) + git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'long') + const { ctx } = await boot({ maxFileBytes: 64 }) + const session = ctx.sessions.create(SessionId('compare'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\n') + await writeFile(join(cwd, 'new.txt'), 'n1\n') + await rm(join(cwd, 'b.txt')) + await rename(join(cwd, 'same.txt'), join(cwd, 'moved.txt')) + await writeFile(join(cwd, 'long.txt'), `${'0123456789'.repeat(10)}\nmore\n`) + await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255)) + toolCall(session, 1, 'bash', { command: 'x' }) + await mutate(ctx, session, 1, 'write', { file_path: '.env', content: 'A=1\nB=2\n' }, () => writeFile(join(cwd, '.env'), 'A=1\nB=2\n')) + endTurn(session, 1) + await settle(ctx, session) + const seq = announcedSeq(session) + const [recorded] = changes(ctx, session) + expect(recorded!.files.map(file => file.display)).toEqual(['.env', 'a.txt', 'b.txt', 'bin.dat', 'long.txt', 'moved.txt', 'new.txt']) + const diff = (index: number) => ctx.workspaceChanges.diff(session.id, seq, index, signal) + expect(await diff(0)).toEqual({ + kind: 'text', path: '.env', display: '.env', before: false, after: true, coarse: false, + hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 2, lines: ['+A=1', '+B=2'] }], + }) + expect(await diff(1)).toEqual({ + kind: 'text', path: 'a.txt', display: 'a.txt', before: true, after: true, coarse: false, + hunks: [{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 3, lines: [' l1', '-l2', '+l2 model', ' l3'] }], + }) + expect(await diff(2)).toMatchObject({ kind: 'text', path: 'b.txt', before: true, after: false, hunks: [{ lines: ['-x'] }] }) + expect(await diff(3)).toEqual({ kind: 'binary', path: 'bin.dat', display: 'bin.dat' }) + // A snapshot blob beyond the byte cap keeps its git counts but serves no lines. + expect(recorded!.files[4]).toMatchObject({ path: 'long.txt', added: 1, deleted: 0 }) + expect(await diff(4)).toEqual({ kind: 'oversized', path: 'long.txt', display: 'long.txt' }) + // A rename compares the old path's turn-start content with the new path's turn-end content. + expect(await diff(5)).toEqual({ kind: 'text', path: 'moved.txt', display: 'moved.txt', before: true, after: true, coarse: false, hunks: [] }) + expect(await diff(6)).toMatchObject({ kind: 'text', path: 'new.txt', before: false, after: true, hunks: [{ lines: ['+n1'] }] }) + expect(await diff(7)).toBeUndefined() + expect(await ctx.workspaceChanges.diff(session.id, seq + 1, 0, signal)).toBeUndefined() + expect(await ctx.workspaceChanges.diff(SessionId('elsewhere'), seq, 0, signal)).toBeUndefined() + ctx.emit('session/disposed', session) + expect(await diff(0)).toBeUndefined() + }) + it('places files above the working directory and outside the repository by their display rule', async () => { const root = await repository() const cwd = join(root, 'pkg') @@ -119,7 +167,8 @@ describe('workspace-changes in a repository', () => { await settle(ctx, session) await writeFile(join(root, 'a.txt'), 'changed\n') await writeFile(join(cwd, 'inner.txt'), 'inner\n') - toolCall(session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' }) + await mutate(ctx, session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' }, + () => writeFile(join(outside, 'note.txt'), 'one\ntwo\nthree\n')) endTurn(session, 1, 'blocked') await settle(ctx, session) const [recorded] = changes(ctx, session) @@ -128,6 +177,7 @@ describe('workspace-changes in a repository', () => { { path: 'inner.txt', display: 'inner.txt', added: 1, deleted: 0 }, { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 3, deleted: 0 }, ]) + expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 2, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+one', '+two', '+three'] }] }) }) it('records inside the turn when the agent stops, and again after turn/end only when tools settled later', async () => { @@ -135,7 +185,6 @@ describe('workspace-changes in a repository', () => { const { ctx } = await boot() const session = ctx.sessions.create(SessionId('stopping'), { meta: { cwd } }) const agent = { session } as never - const signal = new AbortController().signal startTurn(session, 1) await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal }) expect(changes(ctx, session)).toEqual([]) @@ -197,12 +246,13 @@ describe('workspace-changes in a repository', () => { await settle(ctx, session) for (const name of ['c.txt', 'd.txt', 'e.txt']) await writeFile(join(cwd, name), `${name}\n`) toolCall(session, 1, 'bash', { command: 'x' }) - toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l1\n', newText: 'l1\n' }] } }) + await mutate(ctx, session, 1, 'edit', { file_path: 'a.txt', old_string: 'l1', new_string: 'l1' }, () => Promise.resolve()) endTurn(session, 1) await settle(ctx, session) const [recorded] = changes(ctx, session) expect(recorded!.total).toBe(3) expect(recorded!.files.map(file => file.display)).toEqual(['c.txt', 'd.txt']) + expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 2, signal)).toBeUndefined() }) it('warns and skips the turn when its git work fails', async () => { @@ -215,8 +265,8 @@ describe('workspace-changes in a repository', () => { await settle(ctx, session) await writeFile(join(cwd, 'x.txt'), 'x\n') toolCall(session, 1, 'bash', { command: 'x' }) - // A repository whose snapshot failed is not summarized from file-tool hunks as if it had no repository. - toolCall(session, 1, 'write', { file_path: 'x.txt', content: 'x\n' }, { meta: { operation: 'create', diffs: [] } }) + // A repository whose snapshot failed is not summarized from captures as if it had no repository. + await mutate(ctx, session, 1, 'write', { file_path: 'y.txt', content: 'y\n' }, () => writeFile(join(cwd, 'y.txt'), 'y\n')) endTurn(session, 1) await settle(ctx, session) expect(changes(ctx, session)).toEqual([]) @@ -237,11 +287,10 @@ describe('workspace-changes in a repository', () => { const session = ctx.sessions.create(SessionId('gitlink'), { meta: { cwd } }) startTurn(session, 1) await settle(ctx, session) - await writeFile(join(sub, 'inner.txt'), 'inner\nedited\n') - toolCall(session, 1, 'edit', { file_path: 'sub/inner.txt' }, { meta: { diffs: [{ path: 'sub/inner.txt', oldText: 'inner', newText: 'inner\nedited' }] } }) - await writeFile(join(cwd, 'top.txt'), 'top\n') - toolCall(session, 1, 'write', { file_path: 'top.txt', content: 'top\n' }, { meta: { operation: 'create', diffs: [] } }) - toolCall(session, 1, 'write', { file_path: 'same.txt', content: 'same\n' }, { meta: { operation: 'update', diffs: [] } }) + await mutate(ctx, session, 1, 'edit', { file_path: 'sub/inner.txt', old_string: 'inner', new_string: 'inner\nedited' }, + () => writeFile(join(sub, 'inner.txt'), 'inner\nedited\n')) + await mutate(ctx, session, 1, 'write', { file_path: 'top.txt', content: 'top\n' }, () => writeFile(join(cwd, 'top.txt'), 'top\n')) + await mutate(ctx, session, 1, 'write', { file_path: 'same.txt', content: 'same\n' }, () => writeFile(join(cwd, 'same.txt'), 'same\n')) endTurn(session, 1) await settle(ctx, session) expect(changes(ctx, session).map(summary => summary.files.map(file => file.display))).toEqual([['top.txt']]) @@ -253,6 +302,7 @@ describe('workspace-changes in a repository', () => { await ctx.plugin(SessionStore) await ctx.plugin(LocalSubprocessRuntime) await expect(ctx.plugin(WorkspaceChanges, { maxFiles: 0 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer maxFiles') + await expect(ctx.plugin(WorkspaceChanges, { diffTimeoutMs: 1.5 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer diffTimeoutMs') }) }) @@ -268,31 +318,68 @@ describe('workspace-changes without a repository', () => { const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } }) startTurn(session, 1) await settle(ctx, session) - await writeFile(join(cwd, 'existing.txt'), 'after\nmore\n') - toolCall(session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, { - meta: { diffs: [{ path: 'existing.txt', oldText: 'before', newText: 'after\nmore' }] }, - }) - toolCall(session, 1, 'edit', { file_path: 'existing.txt', old_string: 'more', new_string: 'more\nagain' }, { - meta: { diffs: [{ path: 'existing.txt', oldText: 'more', newText: 'more\nagain' }] }, - }) + await mutate(ctx, session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, () => writeFile(join(cwd, 'existing.txt'), 'after\nmore\n')) + await mutate(ctx, session, 1, 'edit', { file_path: 'existing.txt', old_string: 'more', new_string: 'more\nagain' }, () => writeFile(join(cwd, 'existing.txt'), 'after\nmore\nagain\n')) // Shell edits and scratch files under a temporary root stay out; a file elsewhere outside the workspace counts. await writeFile(join(cwd, 'shell.txt'), 'shell\nedited\n') toolCall(session, 1, 'bash', { command: 'x' }) - toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, { meta: { diffs: [] } }) - toolCall(session, 1, 'str_replace_editor', { command: 'create', path: join(outside, 'note.txt'), file_text: 'one\ntwo\n' }) + await mutate(ctx, session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, () => Promise.resolve()) + await mutate(ctx, session, 1, 'str_replace_editor', { command: 'create', path: join(outside, 'note.txt'), file_text: 'one\ntwo\n' }, + () => writeFile(join(outside, 'note.txt'), 'one\ntwo\n')) + // A file the shell edits after a file tool touched it is compared by its final content. + await mutate(ctx, session, 1, 'write', { file_path: 'mixed.txt', content: 'tool\n' }, () => writeFile(join(cwd, 'mixed.txt'), 'tool\n')) + await writeFile(join(cwd, 'mixed.txt'), 'tool\nshell\n') + toolCall(session, 1, 'bash', { command: 'x' }) endTurn(session, 1) await settle(ctx, session) expect(changes(ctx, session)).toEqual([{ - turn: 1, cwd, total: 2, added: 5, deleted: 1, + turn: 1, cwd, total: 3, added: 7, deleted: 1, files: [ { path: 'existing.txt', display: 'existing.txt', added: 3, deleted: 1 }, + { path: 'mixed.txt', display: 'mixed.txt', added: 2, deleted: 0 }, { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 2, deleted: 0 }, ], }]) + const seq = announcedSeq(session) + expect(await ctx.workspaceChanges.diff(session.id, seq, 0, signal)).toEqual({ + kind: 'text', path: 'existing.txt', display: 'existing.txt', before: true, after: true, coarse: false, + hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 3, lines: ['-before', '+after', '+more', '+again'] }], + }) expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([]) await expect(stat(join(cwd, '.git'))).rejects.toThrow() }) + it('lists oversized and binary captured files without counts and serves no lines for them', async () => { + const cwd = await scratchDir('dsh-workspace-changes-bounds-', cleanups) + await writeFile(join(cwd, 'grows.txt'), 'small\n') + const { ctx } = await boot({ maxFileBytes: 16 }) + const session = ctx.sessions.create(SessionId('bounds'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + await mutate(ctx, session, 1, 'write', { file_path: 'grows.txt', content: 'x' }, () => writeFile(join(cwd, 'grows.txt'), 'x'.repeat(17))) + await mutate(ctx, session, 1, 'write', { file_path: 'shrinks.txt', content: 'x' }, () => writeFile(join(cwd, 'shrinks.txt'), 'x')) + await mutate(ctx, session, 1, 'write', { file_path: 'bin.dat', content: 'x' }, () => writeFile(join(cwd, 'bin.dat'), Uint8Array.of(65, 0, 66))) + // A directory at the path is neither absent nor a file, so the path is not tracked. + await mutate(ctx, session, 1, 'write', { file_path: 'dir', content: 'x' }, () => mkdir(join(cwd, 'dir'))) + endTurn(session, 1) + await settle(ctx, session) + const [recorded] = changes(ctx, session) + expect(recorded!.files).toEqual([ + { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true }, + { path: 'grows.txt', display: 'grows.txt', added: 0, deleted: 0, oversized: true }, + { path: 'shrinks.txt', display: 'shrinks.txt', added: 1, deleted: 0 }, + ]) + const seq = announcedSeq(session) + expect(await ctx.workspaceChanges.diff(session.id, seq, 0, signal)).toEqual({ kind: 'binary', path: 'bin.dat', display: 'bin.dat' }) + expect(await ctx.workspaceChanges.diff(session.id, seq, 1, signal)).toEqual({ kind: 'oversized', path: 'grows.txt', display: 'grows.txt' }) + expect(await ctx.workspaceChanges.diff(session.id, seq, 2, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+x'] }] }) + // Every copy lives under the Session's temporary directory and goes with it. + const scratch = (await readdir(tmpdir())).filter(name => name.startsWith('dsh-workspace-changes-')) + expect(scratch.length).toBeGreaterThan(0) + ctx.emit('session/disposed', session) + await settle(ctx, session) + }) + it('drops a disposed session’s recorder and starts afresh on its next turn', async () => { const cwd = await repository() const { ctx, fiber } = await boot() @@ -334,10 +421,11 @@ describe('workspace-changes without a repository', () => { for (const session of sessions) { startTurn(session, 1) await settle(ctx, session) - toolCall(session, 1, 'bash', { command: 'x' }) + await mutate(ctx, session, 1, 'write', { file_path: 'w.txt', content: 'w\n' }, () => writeFile(join(cwd, 'w.txt'), 'w\n')) endTurn(session, 1) await settle(ctx, session) expect(changes(ctx, session)).toEqual([]) + await rm(join(cwd, 'w.txt')) } await ctx.waterfall('tools/pre-execute', {} as never, () => Promise.resolve(undefined as never)) }) @@ -355,11 +443,12 @@ describe('workspace-changes without git', () => { await settle(ctx, session) await writeFile(join(cwd, `${turn}.txt`), 'x\n') toolCall(session, turn, 'bash', { command: 'x' }) - if (turn === 2) toolCall(session, turn, 'write', { file_path: 'w.txt', content: 'w\n' }, { meta: { diffs: [] } }) + if (turn === 2) await mutate(ctx, session, turn, 'write', { file_path: 'w.txt', content: 'w\n' }, () => writeFile(join(cwd, 'w.txt'), 'w\n')) endTurn(session, turn) await settle(ctx, session) } expect(changes(ctx, session)).toEqual([{ turn: 2, cwd, total: 1, added: 1, deleted: 0, files: [{ path: 'w.txt', display: 'w.txt', added: 1, deleted: 0 }] }]) + expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 0, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+w'] }] }) expect(info).toHaveBeenCalledTimes(1) expect(info.mock.calls[0]![0]).toContain('git is unavailable') }) diff --git a/packages/deliverables/workspace-changes/tests/support.ts b/packages/deliverables/workspace-changes/tests/support.ts index 3e6b69fd8b..c7087aa26d 100644 --- a/packages/deliverables/workspace-changes/tests/support.ts +++ b/packages/deliverables/workspace-changes/tests/support.ts @@ -50,6 +50,20 @@ export function toolCall(session: Session, turn: number, name: string, args: unk }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) } +/** + * Apply a file-tool mutation the way the runtime does: announce it through + * `tools/pre-execute` so the recorder captures the path, apply it, then log + * the settled call. + */ +export async function mutate( + ctx: Context, session: Session, turn: number, name: string, args: unknown, apply: () => Promise, + result: { meta?: unknown; isError?: boolean } = {}, +) { + await ctx.waterfall('tools/pre-execute', { agent: { session }, name, arguments: args } as never, () => Promise.resolve(undefined as never)) + await apply() + return toolCall(session, turn, name, args, result) +} + /** Close the step and the turn. */ export function endTurn(session: Session, turn: number, reason: 'completed' | 'blocked' = 'completed'): void { session.append('step/end', { turn, step: 1 }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index d79b88eb8e..eb130ae4d8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3108,8 +3108,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'workspaceChanges', - summary: 'Serves the summaries the recorder keeps for live Sessions.', - description: 'Serves the summaries the recorder keeps for live Sessions.', + summary: 'Serves the summaries and file comparisons the recorder keeps for live Sessions.', + description: 'Serves the summaries and file comparisons the recorder keeps for live Sessions.', methods: [ { signature: 'summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined', @@ -3117,6 +3117,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'sessionId', description: 'the Session that appended the event.' }, { name: 'seq', description: 'the event\'s sequence number.' }], returns: 'the summary, or undefined once its Session was disposed or when this Host never recorded it.', }, + { + signature: 'diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise', + description: 'Compare one listed file\'s contents at turn start and turn end.', + parameters: [{ name: 'sessionId', description: 'the Session that appended the event.' }, { name: 'seq', description: 'the event\'s sequence number.' }, { name: 'index', description: 'the file\'s index in the summary\'s `files`.' }, { name: 'signal', description: 'cancels the reads.' }], + returns: 'the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index.', + throws: ['when a snapshot read fails for a live Session.'], + }, ], }, { @@ -6844,7 +6851,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WorkspaceChangedFile', - declaration: 'export interface WorkspaceChangedFile {\n path: string;\n display: string;\n added: number;\n deleted: number;\n binary?: true;\n}', + declaration: 'export interface WorkspaceChangedFile {\n path: string;\n display: string;\n added: number;\n deleted: number;\n binary?: true;\n oversized?: true;\n}', }, { name: 'WorkspaceChangesSummary', @@ -6866,6 +6873,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkspaceDeleteValue', declaration: 'export interface WorkspaceDeleteValue {\n readonly deleted: true;\n}', }, + { + name: 'WorkspaceDiffHunk', + declaration: 'export interface WorkspaceDiffHunk {\n oldStart: number;\n oldLines: number;\n newStart: number;\n newLines: number;\n lines: string[];\n}', + }, { name: 'WorkspaceDirectoryEntry', declaration: 'export interface WorkspaceDirectoryEntry {\n readonly name: string;\n readonly type: \'file\' | \'directory\' | \'other\';\n readonly size?: number;\n}', @@ -6882,6 +6893,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkspaceFileChange', declaration: 'export type WorkspaceFileChange = {\n readonly absolutePath: string;\n readonly version: string;\n} | {\n readonly absolutePath: string;\n readonly absent: true;\n};', }, + { + name: 'WorkspaceFileDiff', + declaration: 'export type WorkspaceFileDiff = {\n kind: \'text\';\n path: string;\n display: string;\n before: boolean;\n after: boolean;\n hunks: WorkspaceDiffHunk[];\n coarse: boolean;\n} | {\n kind: \'binary\';\n path: string;\n display: string;\n} | {\n kind: \'oversized\';\n path: string;\n display: string;\n};', + }, { name: 'WorkspaceFileRange', declaration: 'export interface WorkspaceFileRange {\n readonly offset?: number;\n readonly limit?: number;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46fa1b148e..d487c9eb71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2989,6 +2989,9 @@ importers: '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: workspace:^ + version: link:../ui-sidebar-right '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4c37a4a93e..0e2e30c8e0 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -259,6 +259,7 @@ export const EVENT_WALK_EXEMPTIONS: Record = { */ export const LINK_MAP: Readonly> = { WorkspaceChangesSummary: 'deliverables.md', + WorkspaceFileDiff: 'deliverables.md', Reload: 'boot.md', PluginInfo: 'boot.md', BundleInfo: 'boot.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 6e08a4a474..31365887d2 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -255,7 +255,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'workspace-changes', title: 'Host per-turn changed-file summaries', mode: 'core', - note: 'Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn.', + note: 'Serves the summary each workspace/changes event announced and each listed file\'s turn-start and turn-end comparison, by Session and event sequence, until that Session is disposed; the log carries only the turn.', }, { key: 'terminalController', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 8baffa6cba..d0bb2b04cc 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2177,6 +2177,16 @@ "symbol": "WorkspaceChangesSummary", "source": "packages/deliverables/workspace-changes/src/types.ts" }, + { + "doc": "docs/subsystems/deliverables.md", + "symbol": "WorkspaceDiffHunk", + "source": "packages/deliverables/workspace-changes/src/types.ts" + }, + { + "doc": "docs/subsystems/deliverables.md", + "symbol": "WorkspaceFileDiff", + "source": "packages/deliverables/workspace-changes/src/types.ts" + }, { "doc": "docs/subsystems/deliverables.md", "symbol": "WorkspaceChanges", diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index 00baa488de..d78a2bd927 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -105,11 +105,11 @@ - text: 已编辑 4 个文件 +6 -1 - list: - listitem: - - button "在侧边栏打开 app.local": app.local +1 -0 + - button "查看 app.local 的改动": app.local +1 -0 - listitem: - - button "在侧边栏打开 intro.md": intro.md +1 -1 + - button "查看 intro.md 的改动": intro.md +1 -1 - listitem: - - button "在侧边栏打开 notes.txt": notes.txt +1 -0 + - button "查看 notes.txt 的改动": notes.txt +1 -0 - button "展开全部 4 个改动文件": - text: 全部 4 个文件 - img From 2440937459ea30c5b3425393ac281ac28c2aa994 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 13:37:22 +0800 Subject: [PATCH 25/37] fix(workspace-changes): list oversized pairs, bound capture reads, and resolve paths through symlinked ancestors Two oversized captures are no longer treated as unchanged, so the file is listed as oversized instead of vanishing; an oversized side outranks a binary one in the card and the tab alike. Captures read at most maxFileBytes + 1 bytes through a file handle, a missing file resolves through its nearest existing ancestor so a path created through a directory symlink keeps one canonical spelling, and ls-tree matches paths literally. The comparison tab draws at most 5,000 lines. Docs record that comparisons serve whole file text beyond the workspace root, the newline normalization, and the coarse payload bound; the card note's row behavior is current again. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 4 +- .../2026-09-11-turn-changed-files-card.zh.md | 4 +- ...-09-15-changed-file-diff-preview.i18n.yaml | 4 +- .../2026-09-15-changed-file-diff-preview.md | 2 +- ...2026-09-15-changed-file-diff-preview.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 5 +- packages/client/ui-deliverables/README.zh.md | 5 +- .../src/client/DiffPreview.tsx | 23 +++++++- .../ui-deliverables/src/client/locales.ts | 4 +- .../tests/deliverables.client.spec.tsx | 9 +++ .../tests/diff-preview.client.spec.tsx | 21 ++++++- .../workspace-changes/README.i18n.yaml | 4 +- .../deliverables/workspace-changes/README.md | 6 +- .../workspace-changes/README.zh.md | 6 +- .../workspace-changes/package.json | 2 +- .../workspace-changes/src/capture.ts | 50 +++++++++++----- .../deliverables/workspace-changes/src/git.ts | 12 ++-- .../workspace-changes/src/index.ts | 4 +- .../workspace-changes/src/paths.ts | 26 ++++++--- .../workspace-changes/src/recorder.ts | 54 +++++++++--------- .../workspace-changes/tests/capture.spec.ts | 11 ++-- .../workspace-changes/tests/compare.spec.ts | 3 + .../workspace-changes/tests/git.spec.ts | 23 +++++++- .../workspace-changes/tests/numstat.spec.ts | Bin 1507 -> 1511 bytes .../workspace-changes/tests/paths.spec.ts | 28 ++++++++- .../workspace-changes/tests/plugin.spec.ts | 52 +++++++++++++++-- 31 files changed, 284 insertions(+), 100 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index 3bedf1aad9..98f7b1ffad 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: 3afae2c21ebba63c83f5cba5e2d1d01960f25684 -2026-09-11-turn-changed-files-card.zh.md: 782e8857834763ad4efbc6d79fb2832577b510ff +2026-09-11-turn-changed-files-card.md: c0c5a3da59cbc82a215ef8970f8e32f65578af6f +2026-09-11-turn-changed-files-card.zh.md: 2b647f31e351a5aea8e911aee17fc7f457124301 diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index 3afae2c21e..c0c5a3da59 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -18,7 +18,7 @@ Git is the default executable on `PATH`; no environment plugin is consulted. Out Changes outside snapshot coverage come from whole-file captures: before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the named file into the Session's temporary directory, once per path per turn, and copies it again at turn end; ignored files, files outside the work tree, and every file-tool edit without a snapshot are listed from a line comparison of the two copies ([comparison decision](2026-09-15-changed-file-diff-preview.md)). Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. -The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar. +The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. Each row opens the file's turn-start and turn-end comparison in the right Sidebar ([comparison decision](2026-09-15-changed-file-diff-preview.md)); with a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and the comparison tab offers the default-application open. The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered. The Client keeps the latest announcement per turn and reads its summary once through the authenticated summary route. @@ -50,4 +50,4 @@ Every turn with tool results costs two snapshots and one diff on the Host, and w The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card whenever a turn changes a file; one dedicated scenario seeds a git repository so the card also carries a shell edit, while the others list their file-tool writes alone. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. -Focused tests cover repositories with real git, directories outside any repository, coverage classification with the metadata the tools really persist, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores. +Focused tests cover repositories with real git, directories outside any repository, coverage classification of captured paths, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index 782e885783..2b647f31e3 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -18,7 +18,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 快照覆盖之外的改动来自整文件捕获:在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把所指文件复制到 Session 的临时目录,每轮每个路径一次,轮次结束时再复制一次;被忽略的文件、工作树之外的文件,以及没有快照时的每一次文件工具编辑,都由两份副本的逐行对比列出([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 -列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。 +列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。每一行在右侧 Sidebar 打开该文件在轮次开始与结束时的对比([对比决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——对比 tab 提供用默认应用打开。 记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。 @@ -50,4 +50,4 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只要某一轮改动了文件就会新增该事件与卡片;一个专门的场景种入 git 仓库,让卡片也带上 shell 的改动,其余场景只列它们的文件工具写入。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 -聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、按工具真实持久化的元数据做覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。 +聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、对已捕获路径的覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。 diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml index fc147110a3..66717906cd 100644 --- a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.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-09-15-changed-file-diff-preview.md -2026-09-15-changed-file-diff-preview.md: df01bc2a7f3b8def92d5da0cee8bc3110a073abd -2026-09-15-changed-file-diff-preview.zh.md: cc5d9379121de15f37b72823e37d243b6a6a8bfb +2026-09-15-changed-file-diff-preview.md: 06b34b33d6c83287b48b158d27039268229a111b +2026-09-15-changed-file-diff-preview.zh.md: 32d319ab5ff0da96eacac21bcd9adcb0fed4e96a diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md index df01bc2a7f..06b34b33d6 100644 --- a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md @@ -36,7 +36,7 @@ Content still lives only as long as the Session in this Host process, as the car ## Consequences -Every file-tool edit reads and writes its whole file once per turn on the Host, bounded by `maxFileBytes`, even for paths the snapshots also cover; a Session's temporary directory holds the copies until disposal. Comparison line counts for uncovered files are first-to-last, not sums. A comparison costs at most two `ls-tree` and two `cat-file` commands plus one bounded line comparison. +Every file-tool edit reads and writes its whole file once per turn on the Host, bounded by `maxFileBytes`, even for paths the snapshots also cover; a Session's temporary directory holds the copies until disposal. Comparison line counts for uncovered files are first-to-last, not sums, and two sides that both exceed the cap list the file as `oversized` rather than dropping it, since unread content is never known to be unchanged. A comparison costs at most two `ls-tree` and two `cat-file` commands plus one bounded line comparison; a comparison that timed out carries every line of both sides, up to twice `maxFileBytes`, and the tab draws at most 5,000 lines of it. The comparison route serves a listed file's complete text to the browser wherever the Host recorded it — ignored files, repository files above the working directory, and files outside the workspace — where the summary route served only paths and counts and the Sidebar's file previews stay under the workspace root; the user authorized those edits, so this is accepted and recorded in both READMEs. The `WorkspaceChangedFile` type gains `oversized`, `WorkspaceChanges` gains `diff`, and two served types, `WorkspaceDiffHunk` and `WorkspaceFileDiff`, join the subsystem page. The Session log is unchanged. The changed-files card's rows are labeled as opening the file's changes in both languages, so the recorded Web scenario's golden changed; the same scenario now opens a shell-appended file's comparison from the snapshots and an ignored file's from its copies. diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md index cc5d937912..32d319ab5f 100644 --- a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md @@ -36,7 +36,7 @@ git 覆盖不到的文件按 Codex 的 turn diff tracker 对比其 `apply_patch` ## Consequences -每次文件工具编辑在 Host 上每轮读写一次整个文件,受 `maxFileBytes` 限制,即使快照也覆盖该路径;Session 的临时目录保存副本直到释放。未覆盖文件的对比行数是首尾对比,不是累加。一次对比最多花费两次 `ls-tree`、两次 `cat-file` 和一次有界的逐行对比。 +每次文件工具编辑在 Host 上每轮读写一次整个文件,受 `maxFileBytes` 限制,即使快照也覆盖该路径;Session 的临时目录保存副本直到释放。未覆盖文件的对比行数是首尾对比,不是累加;两侧都超过上限的文件列为 `oversized` 而不是丢掉,因为没读过的内容永远不能认定为没有改动。一次对比最多花费两次 `ls-tree`、两次 `cat-file` 和一次有界的逐行对比;超时的对比携带两侧的全部行,最多两倍 `maxFileBytes`,tab 最多绘制其中 5000 行。对比路由会把所列文件在 Host 上记录时的完整文本送到浏览器,包括被忽略的文件、工作目录之上的仓库文件和工作区外的文件,而摘要路由只送路径和行数,Sidebar 的文件预览也限定在工作区根目录之内;这些编辑是用户授权的,因此接受这一点,并记入两个 README。 `WorkspaceChangedFile` 类型新增 `oversized`,`WorkspaceChanges` 新增 `diff`,两个提供给客户端的类型 `WorkspaceDiffHunk` 和 `WorkspaceFileDiff` 加入子系统页面。Session 日志不变。改动文件卡片的行在两种语言里都标为打开该文件的改动,因此录制的 Web 场景的黄金文件变了;同一场景现在会从快照打开一个 shell 追加过的文件的对比,从副本打开一个被忽略文件的对比。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index aa734175fc..8a7abd5c73 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: ce41d91277040f8c51eb27d63379368695bae8fd -config-catalog.zh.md: 64dd37959e2304f395a1d6e8c22345453d71c66c +config-catalog.md: 8a7133ccaa54c904af3ebee6b3143b2623bcb70d +config-catalog.zh.md: 2729a197a8db213ecd44a2d3554923187b14de69 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ce41d91277..8a7133ccaa 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3647,8 +3647,8 @@ export interface Config { /** Maximum files carried by one summary; `total` still reports the complete count. */ maxFiles: number /** - * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; - * a larger file is listed without counts or comparison. + * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison. + * A larger file gets no comparison; one captured around a file-tool edit is also listed without counts. */ maxFileBytes: number /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 64dd37959e..2729a197a8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3649,8 +3649,8 @@ export interface Config { /** Maximum files carried by one summary; `total` still reports the complete count. */ maxFiles: number /** - * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; - * a larger file is listed without counts or comparison. + * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison. + * A larger file gets no comparison; one captured around a file-tool edit is also listed without counts. */ maxFileBytes: number /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */ diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 0197f44322..3999f93f9e 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/ui-deliverables/README.md -README.md: f0a82e44441e9cd8a0a0f259af9e903ad9eee693 -README.zh.md: fc689b3c7b969b8ef76098c79de602d076a4218e +README.md: 12c5805ff78ab8d27c6be409cda6d62a8a78e74f +README.zh.md: a23c503ae18583bb3109e877adbc4c3f17e1d7c3 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index f0a82e4444..12c5805ff7 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -56,7 +56,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-diff/session////` through `ctx.sidebarRight.openResource`; the package registers the `changes-diff` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat, which reads the comparison through the authenticated comparison route into a store cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory, and the comparison tab's native open uses the same indexed coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory, and the comparison tab's native open uses the same indexed coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the comparison tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. @@ -104,7 +104,8 @@ These limits define the current deliverables vocabulary. They are current packag - **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. - **Folder and native opening need a Host desktop** — without one the card header is inert and the comparison tab offers no native open; the comparison itself needs only the Host that recorded the turn. -- **Comparisons are unhighlighted unified diffs** — the tab shows plain hunks without syntax colouring or a side-by-side view. +- **Comparisons are unhighlighted unified diffs** — the tab shows plain hunks without syntax colouring or a side-by-side view, and draws at most 5,000 lines before saying so. +- **Comparisons carry whole file text** — the comparison route serves a listed file's complete text wherever the Host recorded it, including ignored files and files outside the workspace root the Sidebar previews are confined to. - **Package-local header glyph** — the card's angle-bracket mark lives in `src/client/icons.tsx` until the shared icon set carries it; its props already match the shared icon contract. - **Files outside the workspace open by absolute path only** — the recorded path is the Host path at recording time; a moved workspace or a different viewing Session cannot relocate it. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index fc689b3c7b..a23c503ae1 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -56,7 +56,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-diff/session////`;本包在 `builtin` 档为该模式注册 `changes-diff` tab 类型,并把其 body 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的对比路由把对比读进一个连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录;对比 tab 的原生打开使用同一组带下标的坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录;对比 tab 的原生打开使用同一组带下标的坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后对比 tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 @@ -104,7 +104,8 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 - **打开文件夹和原生打开需要 Host 桌面**——没有桌面时卡片标题不可点击,对比 tab 也不提供原生打开;对比本身只需要记录了该轮的 Host。 -- **对比是没有高亮的 unified diff**——tab 只显示纯文本 hunk,没有语法着色,也没有左右对照视图。 +- **对比是没有高亮的 unified diff**——tab 只显示纯文本 hunk,没有语法着色,也没有左右对照视图,最多绘制 5000 行并给出提示。 +- **对比携带整个文件的文本**——对比路由会送出所列文件在 Host 上记录时的完整文本,包括被忽略的文件和 Sidebar 预览所限定的工作区根目录之外的文件。 - **包内的标题字形**——卡片的尖括号标记放在 `src/client/icons.tsx` 中,直到共享图标集收录它;其 props 已与共享图标契约一致。 - **工作区外的文件只按绝对路径打开**——记录的路径是记录时的 Host 路径;工作区移动或换一个查看 Session 都无法重新定位它。 diff --git a/packages/client/ui-deliverables/src/client/DiffPreview.tsx b/packages/client/ui-deliverables/src/client/DiffPreview.tsx index 281a9a8048..c94e5eaf7f 100644 --- a/packages/client/ui-deliverables/src/client/DiffPreview.tsx +++ b/packages/client/ui-deliverables/src/client/DiffPreview.tsx @@ -27,6 +27,9 @@ export interface DiffPreviewInjected { /** The body's composed props: the tab it draws, its injected face, and its copy. */ export type DiffPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & InjectFace & PropsLocale +/** Lines drawn before the tab stops; a coarse comparison of a file near the byte cap would otherwise draw every line. */ +export const MAX_RENDERED_LINES = 5000 + /** One drawn line of a hunk with its line numbers on each side. */ interface DiffRow { kind: 'add' | 'del' | 'context' @@ -111,14 +114,32 @@ export function DiffPreview({ ) } +/** + * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total. + * @param hunks - served hunks. + * @returns the hunks with the last one shortened as needed, and whether anything was cut. + */ +export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } { + let budget = MAX_RENDERED_LINES + const kept: WorkspaceDiffHunk[] = [] + for (const hunk of hunks) { + if (budget === 0) return { hunks: kept, truncated: true } + kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) }) + budget -= Math.min(budget, hunk.lines.length) + } + return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) } +} + /** The hunks of a text comparison with their line numbers. */ function TextDiff({ diff, t }: { diff: Extract } & PropsLocale): ReactNode { const note = noteOf(diff) + const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks]) return (
    {note !== undefined &&

    {t(note)}

    } {diff.coarse &&

    {t('diff.coarse')}

    } - {diff.hunks.map((hunk, position) => ( + {truncated &&

    {t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

    } + {hunks.map((hunk, position) => (
    {`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`}
    {hunkRows(hunk).map((row, at) => ( diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 2852518f5a..85cdaefab1 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -43,7 +43,6 @@ export const zh = { 'changes.deleted': '-{count}', 'changes.binary': '二进制', 'changes.openFolder': '打开改动文件所在的文件夹', - 'changes.openFile': '用默认应用打开 {name}', 'changes.all': '全部 {count} 个文件', 'changes.expandAria': '展开全部 {count} 个改动文件', 'changes.collapse': '收起', @@ -61,6 +60,7 @@ export const zh = { 'diff.deleted': '本轮删除的文件', 'diff.unchanged': '两侧内容相同', 'diff.coarse': '逐行对比超时,按整个文件替换显示', + 'diff.truncated': '只显示前 {count} 行', 'diff.openNative': '用默认应用打开', 'diff.openNativeError': '打开失败,点击重试', } @@ -105,7 +105,6 @@ export const en: Record = { 'changes.deleted': '-{count}', 'changes.binary': 'binary', 'changes.openFolder': 'Open the folder containing the changed files', - 'changes.openFile': 'Open {name} in default app', 'changes.all': 'All {count} files', 'changes.expandAria': 'Show all {count} changed files', 'changes.collapse': 'Collapse', @@ -123,6 +122,7 @@ export const en: Record = { 'diff.deleted': 'Deleted in this turn', 'diff.unchanged': 'Both sides hold the same lines', 'diff.coarse': 'Line comparison timed out; shown as a whole-file replacement', + 'diff.truncated': 'Showing the first {count} lines', 'diff.openNative': 'Open in default app', 'diff.openNativeError': 'Could not open. Click to retry.', } diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index d039868f77..a59ea4ad70 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -641,6 +641,10 @@ describe('ChangedFiles card', () => { controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'error' }) const { view: failed } = renderCard(controller) expect(failed.getByText(en['changes.folderError']).closest('[data-error]')).toBeTruthy() + failed.unmount() + controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'nativeUnavailable' }) + const { view: unmapped } = renderCard(controller) + expect(unmapped.getByText(en['changes.folderError']).closest('[data-error]')).toBeTruthy() }) it('renders without a fold for three files or fewer and beside delivery cards', () => { @@ -773,6 +777,11 @@ describe('plugin registration', () => { await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1) expect(tabFace.hooks.changesDiff.getSnapshot()['/api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost) + fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) + await tabFace.reloadPresentedHost() + fetcher.mockResolvedValueOnce(new Response(null, { status: 204 })) + await tabFace.openChanged(SessionId('child-session'), 5, 1) + expect(tabFace.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5&index=1']).toBe('opened') ctx.emit('connection/reset') expect(tabFace.hooks.changesDiff.getSnapshot()).toEqual({}) // A turn that produced nothing yields no vocabulary at all. diff --git a/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx b/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx index 589aedb324..3052775db6 100644 --- a/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx @@ -6,7 +6,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { SessionId } from '@deepseek-ai/dsh-session/types' import { changesDiffAddress, changesDiffUrl, parseChangesDiffAddress, type ChangesDiff } from '../src/changes.ts' import { ChangesDiffStore } from '../src/client/changes-diff.ts' -import { DiffPreview, hunkRows, type DiffPreviewInjected, type DiffPreviewProps } from '../src/client/DiffPreview.tsx' +import { DiffPreview, hunkRows, MAX_RENDERED_LINES, renderedHunks, type DiffPreviewInjected, type DiffPreviewProps } from '../src/client/DiffPreview.tsx' import { changesDiffDefinition } from '../src/client/diff-definition.ts' import { PresentedOpenController } from '../src/client/present-open.ts' import { en, zh } from '../src/client/locales.ts' @@ -210,6 +210,25 @@ describe('DiffPreview', () => { expect(view.queryByRole('button')).toBeNull() }) + it('stops drawing after the rendered-line cap and says so', () => { + const long = (count: number, prefix = '+'): string[] => Array.from({ length: count }, (_, at) => `${prefix}line ${at}`) + const whole = { oldStart: 1, oldLines: 0, newStart: 1, newLines: MAX_RENDERED_LINES + 2, lines: long(MAX_RENDERED_LINES + 2) } + const cut = renderedHunks([whole]) + expect(cut.truncated).toBe(true) + expect(cut.hunks[0]!.lines).toHaveLength(MAX_RENDERED_LINES) + const small = { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] } + expect(renderedHunks([small, small])).toEqual({ hunks: [small, small], truncated: false }) + // A later hunk past the budget is dropped whole, an earlier one shortened. + const two = renderedHunks([{ ...whole, lines: long(MAX_RENDERED_LINES) }, small]) + expect(two.hunks).toHaveLength(1) + expect(two.truncated).toBe(true) + const diffs = new ChangesDiffStore() + diffs.state.set({ [URL_]: { ...text, hunks: [{ ...whole, lines: long(MAX_RENDERED_LINES + 1) }] } }) + const { view } = mount(diffs) + expect(view.container.querySelectorAll('[data-diff-line]')).toHaveLength(MAX_RENDERED_LINES) + expect(view.container.querySelector('[data-diff-truncated]')?.textContent).toBe(`Showing the first ${MAX_RENDERED_LINES} lines`) + }) + it('refuses an address it did not mint', () => { const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) expect(() => mount(undefined, undefined, en, 'dsh-resource://file/session/viewed/a.ts')).toThrow('not a comparison address') diff --git a/packages/deliverables/workspace-changes/README.i18n.yaml b/packages/deliverables/workspace-changes/README.i18n.yaml index 24b01fb067..aaf8176288 100644 --- a/packages/deliverables/workspace-changes/README.i18n.yaml +++ b/packages/deliverables/workspace-changes/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/deliverables/workspace-changes/README.md -README.md: 80983656ac679202695abeff8a505e549785f4d2 -README.zh.md: dfe9e82abd13d408426cb8f0800dccbd25073c0c +README.md: 06b40006750cb6fb8cad614af9c275f9b7243e02 +README.zh.md: 8c49cfebb8cf79117c55150d8cc4b0de8ba3ac3a diff --git a/packages/deliverables/workspace-changes/README.md b/packages/deliverables/workspace-changes/README.md index 80983656ac..06b4000675 100644 --- a/packages/deliverables/workspace-changes/README.md +++ b/packages/deliverables/workspace-changes/README.md @@ -38,12 +38,12 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the | `timeoutMs` | `30000` | Milliseconds one git command may run before the turn's record is abandoned | | `outputMaxBytes` | `8388608` | Bytes of git output retained per command; a larger diff listing abandons the record | | `maxFiles` | `500` | Maximum files carried by one summary; `total` still reports the complete count | -| `maxFileBytes` | `2097152` | Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; a larger file is listed without counts or comparison | +| `maxFileBytes` | `2097152` | Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; a larger file gets no comparison, and one captured around a file-tool edit is also listed without counts | | `diffTimeoutMs` | `100` | Milliseconds a line comparison may run before it degrades to whole-file replacement | Every Session with a working directory and no subagent origin is recorded; subagent Sessions are not. Snapshots are written through a private index into a temporary object directory owned by the Session, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. Session disposal removes the directory. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. A working directory outside any git repository takes no snapshots. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — no repository is located either, and the plugin logs that once. Either way the summary lists the file-tool edits alone, as described next, with the working directory as the workspace; shell edits are absent. -Before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the file at its path into the Session's temporary directory, once per path per turn, and copies it again at turn end; the copies are named by the SHA-1 of their bytes, so identical content is stored once. This needs no git. Paths the snapshots cover keep their git counts; the copies serve the other paths — files matching an ignore pattern, files outside the repository, and every file-tool edit when there is no snapshot — with counts from a line comparison of the two copies, so repeated edits to one file count once and a shell edit after a file-tool edit is included. A path whose content is unchanged at turn end is not listed. A copy larger than `maxFileBytes` is not stored: the file is listed with `oversized` and no counts. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. Changes made only through shell commands outside the snapshot coverage are not recorded. +Before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the file at its path into the Session's temporary directory, once per path per turn, and copies it again at turn end; the copies are named by the SHA-1 of their bytes, so identical content is stored once. This needs no git. Paths the snapshots cover keep their git counts; the copies serve the other paths — files matching an ignore pattern, files outside the repository, and every file-tool edit when there is no snapshot — with counts from a line comparison of the two copies, so repeated edits to one file count once and a shell edit after a file-tool edit is included. A path whose content is unchanged at turn end is not listed. A copy larger than `maxFileBytes` is not stored: the file is listed with `oversized` and no counts, and a path whose both sides are that large is listed too, since unread content is never known to be unchanged. A path whose only difference is a missing final newline compares as unchanged, while git still counts that line. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. Changes made only through shell commands outside the snapshot coverage are not recorded. Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The `workspace/changes` event carries only the turn number; `ctx.workspaceChanges.summary(sessionId, seq)` returns the summary the event with that sequence announced, or undefined once the Session is disposed or when this Host process never recorded it. `ctx.workspaceChanges.diff(sessionId, seq, index, signal)` compares the listed file at that index: hunks with three context lines from the two snapshot trees or the two copies, `binary` for a side git reported as binary or that holds a NUL byte, `oversized` for a side larger than `maxFileBytes`. A line comparison that runs longer than `diffTimeoutMs` degrades to one hunk replacing every line, marked `coarse`. A conversation reopened after a Host restart therefore has no card, and no comparison, for its earlier turns. @@ -93,6 +93,8 @@ Nothing here enters a model request, so provider cache reuse is unaffected. - A working directory outside any git repository lists file-tool edits only, so shell edits are missing from its card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably. - Outside snapshot coverage only paths a file tool names are captured: a file only a shell command changes there is absent, and a file both changed before its first file-tool call is compared from that call onward. - Every file-tool edit copies its whole file once per turn, up to `maxFileBytes`, even for paths the snapshots also cover; the copies go with the Session's temporary directory. +- A comparison serves the listed file's complete text to the client, including ignored files, repository files above the working directory, and files outside the workspace; the summary route serves only paths and counts. A deployment that must keep such content on the Host composes this plugin out. +- A comparison that degrades to whole-file replacement carries every line of both sides, up to twice `maxFileBytes`. - Windows paths keep native separators in `path`; `display` is always slash-separated. diff --git a/packages/deliverables/workspace-changes/README.zh.md b/packages/deliverables/workspace-changes/README.zh.md index dfe9e82abd..8c49cfebb8 100644 --- a/packages/deliverables/workspace-changes/README.zh.md +++ b/packages/deliverables/workspace-changes/README.zh.md @@ -38,12 +38,12 @@ kind: "package-reference" | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 | | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 | | `maxFiles` | `500` | 单份摘要携带的最大文件数;`total` 仍报告完整数量 | -| `maxFileBytes` | `2097152` | 文件在文件工具编辑前后被捕获、或从快照读出做对比所允许的最大字节数;更大的文件只列出,不带行数和对比 | +| `maxFileBytes` | `2097152` | 文件在文件工具编辑前后被捕获、或从快照读出做对比所允许的最大字节数;更大的文件不提供对比,其中在文件工具编辑前后被捕获的也不带行数 | | `diffTimeoutMs` | `100` | 逐行对比允许运行的毫秒数,超时后退化为整文件替换 | 有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。不在任何 git 仓库内的工作目录不做快照。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——同样定位不到仓库,插件记录一次日志。两种情况下摘要都只列下文所述的文件工具编辑,并以工作目录作为工作区;shell 的改动不会出现。 -在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把该路径上的文件复制到 Session 的临时目录,每轮每个路径只复制第一次,轮次结束时再复制一次;副本按其字节的 SHA-1 命名,相同内容只存一份。这一步不需要 git。快照覆盖到的路径保留 git 的行数;其余路径由副本提供,也就是匹配忽略模式的文件、仓库之外的文件,以及没有快照时的每一次文件工具编辑,行数来自两份副本的逐行对比,因此同一文件的重复编辑只计一次,文件工具编辑之后的 shell 改动也包含在内。轮次结束时内容没有变化的路径不会列出。超过 `maxFileBytes` 的副本不会保存:该文件列出时带 `oversized`,没有行数。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。快照覆盖范围之外只通过 shell 命令做出的改动不会被记录。 +在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把该路径上的文件复制到 Session 的临时目录,每轮每个路径只复制第一次,轮次结束时再复制一次;副本按其字节的 SHA-1 命名,相同内容只存一份。这一步不需要 git。快照覆盖到的路径保留 git 的行数;其余路径由副本提供,也就是匹配忽略模式的文件、仓库之外的文件,以及没有快照时的每一次文件工具编辑,行数来自两份副本的逐行对比,因此同一文件的重复编辑只计一次,文件工具编辑之后的 shell 改动也包含在内。轮次结束时内容没有变化的路径不会列出。超过 `maxFileBytes` 的副本不会保存:该文件列出时带 `oversized`,没有行数;两侧都这么大的路径同样列出,因为没读过的内容永远不能认定为没有改动。只差一个末尾换行的路径对比为两侧相同,而 git 仍会把那一行计入行数。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。快照覆盖范围之外只通过 shell 命令做出的改动不会被记录。 每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。`workspace/changes` 事件只携带轮号;`ctx.workspaceChanges.summary(sessionId, seq)` 返回该序号的事件宣告的摘要,Session 已释放或本 Host 进程从未记录时返回 undefined。`ctx.workspaceChanges.diff(sessionId, seq, index, signal)` 对比该下标所列的文件:从两棵快照树或两份副本得出带三行上下文的 hunk;git 报告为二进制或某一侧含 NUL 字节时返回 `binary`;某一侧超过 `maxFileBytes` 时返回 `oversized`。逐行对比运行超过 `diffTimeoutMs` 时退化为一个替换全部行的 hunk,并标记 `coarse`。因此 Host 重启后重新打开的对话,先前轮次既没有卡片也没有对比。 @@ -93,6 +93,8 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ - 不在任何 git 仓库内的工作目录只列文件工具的编辑,卡片里因此没有 shell 改动;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。 - 快照覆盖范围之外只捕获文件工具点名的路径:那里只被 shell 命令改动的文件不会出现,在首次文件工具调用之前被两者都改过的文件从该调用起开始对比。 - 每次文件工具编辑都会把整个文件复制一次,每轮每个路径一次,上限 `maxFileBytes`,即使快照也覆盖该路径;副本随 Session 的临时目录一起删除。 +- 对比会把所列文件的完整文本送到客户端,包括被忽略的文件、工作目录之上的仓库文件和工作区外的文件;摘要路由只送路径和行数。必须把这类内容留在 Host 上的部署应把本插件组合出去。 +- 退化为整文件替换的对比携带两侧的全部行,最多两倍 `maxFileBytes`。 - Windows 路径在 `path` 中保留原生分隔符;`display` 始终用斜杠分隔。 diff --git a/packages/deliverables/workspace-changes/package.json b/packages/deliverables/workspace-changes/package.json index 39ca514741..1b2878e306 100644 --- a/packages/deliverables/workspace-changes/package.json +++ b/packages/deliverables/workspace-changes/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-workspace-changes", - "description": "Per-turn workspace file changes recorded from git working-tree snapshots for the DeepSeek Harness", + "description": "Per-turn workspace file changes recorded from git working-tree snapshots and whole-file captures, with per-file comparisons, for the DeepSeek Harness", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" diff --git a/packages/deliverables/workspace-changes/src/capture.ts b/packages/deliverables/workspace-changes/src/capture.ts index 907e1af41a..71f2ce61df 100644 --- a/packages/deliverables/workspace-changes/src/capture.ts +++ b/packages/deliverables/workspace-changes/src/capture.ts @@ -5,7 +5,7 @@ * survive later edits without depending on git. */ import { createHash } from 'node:crypto' -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { mkdir, open, writeFile } from 'node:fs/promises' import { join } from 'node:path' /** Bytes git inspects for a NUL byte before treating content as binary. */ @@ -26,37 +26,61 @@ function isMissing(error: unknown): boolean { } /** - * Store the current content of one path. + * Store the current content of one path. At most `maxBytes + 1` bytes are + * read, so a file that grows past the cap while it is read costs no more + * memory than the cap. * @param absolute - canonical absolute path of the file. * @param directory - directory holding content-addressed copies; created when missing. * @param maxBytes - inclusive byte cap on a stored copy. * @returns the capture, or undefined for a path that is neither absent nor a regular file. + * @throws when the path cannot be opened or read for a reason other than absence, or the copy cannot be written. */ export async function captureFile(absolute: string, directory: string, maxBytes: number): Promise { - let size: number + let handle try { - const info = await stat(absolute) - if (!info.isFile()) return undefined - size = info.size + handle = await open(absolute, 'r') } catch (error: unknown) { - if (isMissing(error)) return { kind: 'absent' } - throw error + /* v8 ignore next -- an unopenable present file needs permissions the tests cannot revoke on every host. */ + if (!isMissing(error)) throw error + return { kind: 'absent' } + } + let bytes: Buffer + try { + if (!(await handle.stat()).isFile()) return undefined + const probe = Buffer.allocUnsafe(maxBytes + 1) + let length = 0 + while (length < probe.length) { + const { bytesRead } = await handle.read(probe, length, probe.length - length, length) + if (bytesRead === 0) break + length += bytesRead + } + if (length > maxBytes) return { kind: 'oversized' } + bytes = probe.subarray(0, length) + } finally { + await handle.close() } - if (size > maxBytes) return { kind: 'oversized' } - const bytes = await readFile(absolute) - if (bytes.byteLength > maxBytes) return { kind: 'oversized' } const file = join(directory, createHash('sha1').update(bytes).digest('hex')) await mkdir(directory, { recursive: true }) // Identical content across paths and turns shares one copy; `wx` keeps an existing copy as is. await writeFile(file, bytes, { flag: 'wx' }).catch((error: unknown) => { + /* v8 ignore next -- a copy that cannot be written needs a directory the tests cannot make unwritable on every host. */ if ((error as { code?: unknown }).code !== 'EEXIST') throw error }) return { kind: 'file', file, binary: bytes.subarray(0, BINARY_PROBE_BYTES).includes(0) } } -/** Whether two captures hold the same content. */ +/** + * Whether two captures are known to hold the same content. Two absent sides + * are the same; two stored copies are the same when their bytes hash alike; + * an oversized side is never known to match anything, since its content was + * not read. + * @param a - one side. + * @param b - the other side. + * @returns true only when both sides are known to match. + */ export function sameCapture(a: Capture, b: Capture): boolean { - return a.kind === b.kind && (a.kind !== 'file' || b.kind !== 'file' || a.file === b.file) + if (a.kind === 'absent' || b.kind === 'absent') return a.kind === b.kind + return a.kind === 'file' && b.kind === 'file' && a.file === b.file } /** A non-blank string, or undefined. */ diff --git a/packages/deliverables/workspace-changes/src/git.ts b/packages/deliverables/workspace-changes/src/git.ts index c34812f673..c49ad66897 100644 --- a/packages/deliverables/workspace-changes/src/git.ts +++ b/packages/deliverables/workspace-changes/src/git.ts @@ -189,19 +189,22 @@ export interface TreeBlob { export async function treeBlob( git: GitRunner, workspace: GitWorkspace, tree: string, path: string, signal: AbortSignal, ): Promise { - const result = ok(await git.run(['ls-tree', '-z', '-l', tree, '--', path], { cwd: workspace.root, env: workspace.env, signal }), 'git ls-tree') - const entry = result.stdout.split('\0')[0] ?? '' + // The path is a pathspec; literal matching keeps `*`, `?`, and `[` in a file name from selecting another entry. + const result = ok(await git.run(['ls-tree', '-z', '-l', tree, '--', path], { + cwd: workspace.root, env: { ...workspace.env, GIT_LITERAL_PATHSPECS: '1' }, signal, + }), 'git ls-tree') + const entry = result.stdout.split('\0')[0] as string const match = /^\d+ (\S+) ([0-9a-f]+) +(\d+)\t/.exec(entry) if (match === null || match[1] !== 'blob') return null return { oid: match[2] as string, size: Number(match[3]) } } /** - * The text of one blob. + * The text of one blob whose size {@link treeBlob} reported within the cap. * @param git - command runner. * @param workspace - addressed repository. * @param oid - blob id. - * @param maxBytes - inclusive byte cap; a larger blob throws. + * @param maxBytes - inclusive byte cap the caller checked the blob's size against. * @param signal - cancellation. * @returns the blob decoded as UTF-8. */ @@ -209,6 +212,7 @@ export async function blobText( git: GitRunner, workspace: GitWorkspace, oid: string, maxBytes: number, signal: AbortSignal, ): Promise { const result = ok(await git.run(['cat-file', 'blob', oid], { cwd: workspace.root, env: workspace.env, maxBytes, signal }), 'git cat-file') + /* v8 ignore next -- callers size the blob with treeBlob first; a blob is immutable, so the cap cannot be exceeded here. */ if (result.truncated) throw new Error(`blob ${oid} exceeds ${maxBytes} bytes`) return result.stdout } diff --git a/packages/deliverables/workspace-changes/src/index.ts b/packages/deliverables/workspace-changes/src/index.ts index b29d0a355b..c05787d931 100644 --- a/packages/deliverables/workspace-changes/src/index.ts +++ b/packages/deliverables/workspace-changes/src/index.ts @@ -38,8 +38,8 @@ export interface Config { /** Maximum files carried by one summary; `total` still reports the complete count. */ maxFiles: number /** - * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; - * a larger file is listed without counts or comparison. + * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison. + * A larger file gets no comparison; one captured around a file-tool edit is also listed without counts. */ maxFileBytes: number /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */ diff --git a/packages/deliverables/workspace-changes/src/paths.ts b/packages/deliverables/workspace-changes/src/paths.ts index f207eb1e17..25cc47ed70 100644 --- a/packages/deliverables/workspace-changes/src/paths.ts +++ b/packages/deliverables/workspace-changes/src/paths.ts @@ -1,7 +1,7 @@ /** Path classification and display forms for changed files. */ import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { isAbsolute, relative, sep } from 'node:path' +import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' /** * Slash-separated form of a native relative path. @@ -40,16 +40,26 @@ export async function temporaryRoots(candidates: readonly string[] = ['/tmp', tm } /** - * Symlink-resolved path when the target exists, otherwise the lexical path. + * Symlink-resolved path. A path that does not exist yet is resolved through + * its nearest existing ancestor, so a file created through a directory + * symlink has the same canonical spelling before and after it exists. * @param path - absolute path. - * @returns the canonical spelling git reports for an existing path. + * @returns the canonical spelling git reports for the path. */ export async function canonicalPath(path: string): Promise { - try { - return await realpath(path) - } catch { - // A missing or unreadable target keeps its lexical spelling. - return path + const missing: string[] = [] + let head = path + while (true) { + try { + return join(await realpath(head), ...missing) + } catch { + // A missing or unreadable component is kept lexically under its nearest resolvable ancestor. + const parent = dirname(head) + /* v8 ignore next -- the filesystem root always resolves, so the walk ends before reaching it. */ + if (parent === head) return path + missing.unshift(basename(head)) + head = parent + } } } diff --git a/packages/deliverables/workspace-changes/src/recorder.ts b/packages/deliverables/workspace-changes/src/recorder.ts index 53783b5673..380341a85a 100644 --- a/packages/deliverables/workspace-changes/src/recorder.ts +++ b/packages/deliverables/workspace-changes/src/recorder.ts @@ -46,18 +46,19 @@ interface Repository { /** The turn-start snapshot of a located repository. */ interface Baseline extends Repository { tree: string } -/** Where one side of a listed file's content lives. */ +/** Where one readable side of a listed file's content lives. */ type ContentSource = /** A path in a snapshot tree; absence, size, and text are read from git when asked for. */ | { kind: 'snapshot'; repository: Repository; tree: string; path: string } - | Capture + | Exclude -/** The two sides of one listed file, kept beside the served summary. */ -interface FileSources { - before: ContentSource - after: ContentSource - binary: boolean -} +/** + * What a listed file's comparison is served from: a refusal decided when the + * turn was recorded, or the two sides to read and compare when asked for. + */ +type FileSources = + | { refusal: 'binary' | 'oversized' } + | { refusal?: undefined; before: ContentSource; after: ContentSource } /** A served summary with the content sources of its listed files, index-aligned with `summary.files`. */ interface TurnRecord { @@ -89,7 +90,7 @@ function freshState(turn: number): TurnState { /** A listed file with the sources of its two sides. */ interface Listed { file: WorkspaceChangedFile; sources: FileSources } -/** A read side larger than the byte cap. */ +/** A snapshot side larger than the byte cap. */ const OVERSIZED = Symbol('oversized') /** @@ -224,8 +225,7 @@ export class TurnRecorder { const sources = record?.sources[index] if (file === undefined || sources === undefined) return undefined const { path, display } = file - if (sources.binary) return { kind: 'binary', path, display } - if (file.oversized === true) return { kind: 'oversized', path, display } + if (sources.refusal !== undefined) return { kind: sources.refusal, path, display } const combined = AbortSignal.any([signal, this.lifetime.signal]) try { const [before, after] = await Promise.all([this.readSide(sources.before, combined), this.readSide(sources.after, combined)]) @@ -285,12 +285,11 @@ export class TurnRecorder { return this.repository } - /** One side's text, null for an absent file, or {@link OVERSIZED} for a side beyond the byte cap. */ + /** One side's text, null for an absent file, or {@link OVERSIZED} for a snapshot side beyond the byte cap. */ private async readSide(source: ContentSource, signal: AbortSignal): Promise { switch (source.kind) { case 'absent': return null - case 'oversized': return OVERSIZED - case 'file': return readFile(source.file, 'utf8') + case 'file': return readFile(source.file, { encoding: 'utf8', signal }) case 'snapshot': { const { git, workspace } = source.repository const blob = await treeBlob(git, workspace, source.tree, source.path, signal) @@ -318,10 +317,9 @@ export class TurnRecorder { const absolute = resolve(root, entry.path) listed.set(absolute, { file: changedFile(paths, root, absolute, entry), - sources: { + sources: entry.binary ? { refusal: 'binary' } : { before: { kind: 'snapshot', repository, tree: baseline.tree, path: entry.oldPath ?? entry.path }, after: { kind: 'snapshot', repository, tree: after, path: entry.path }, - binary: entry.binary, }, }) } @@ -348,10 +346,7 @@ export class TurnRecorder { const before = state.captures.get(absolute) as Capture const after = await captureFile(absolute, join(await this.scratchDir(), 'captures'), this.env.maxFileBytes) if (after === undefined || sameCapture(before, after)) continue - listed.set(absolute, { - file: changedFile(paths, root, absolute, await this.capturedCounts(before, after)), - sources: { before, after, binary: isBinary(before) || isBinary(after) }, - }) + listed.set(absolute, await this.compared(paths, root, absolute, before, after)) } const sorted = [...listed.values()].sort((a, b) => compareDisplay(a.file, b.file)) // An empty list after an earlier in-turn record supersedes that record. @@ -373,13 +368,20 @@ export class TurnRecorder { state.recordedAfterSeq = event.seq } - /** Line counts of a captured pair; none for a binary or oversized side. */ - private async capturedCounts(before: Capture, after: Capture): Promise { - if (before.kind === 'oversized' || after.kind === 'oversized') return { added: 0, deleted: 0, binary: false, oversized: true } - if (isBinary(before) || isBinary(after)) return { added: 0, deleted: 0, binary: true } - const text = async (side: Capture): Promise => side.kind === 'file' ? readFile(side.file, 'utf8') : null + /** + * The listing of a captured pair: an oversized side lists the file without + * counts and refuses its comparison, a binary side likewise, and two text + * sides carry the counts of their line comparison. + */ + private async compared(paths: Paths, root: string, absolute: string, before: Capture, after: Capture): Promise { + const list = (counts: Counts, sources: FileSources): Listed => ({ file: changedFile(paths, root, absolute, counts), sources }) + if (before.kind === 'oversized' || after.kind === 'oversized') { + return list({ added: 0, deleted: 0, binary: false, oversized: true }, { refusal: 'oversized' }) + } + if (isBinary(before) || isBinary(after)) return list({ added: 0, deleted: 0, binary: true }, { refusal: 'binary' }) + const text = async (side: typeof before): Promise => side.kind === 'file' ? readFile(side.file, 'utf8') : null const { added, deleted } = compareText(await text(before), await text(after), this.env.diffTimeoutMs) - return { added, deleted, binary: false } + return list({ added, deleted, binary: false }, { before, after }) } } diff --git a/packages/deliverables/workspace-changes/tests/capture.spec.ts b/packages/deliverables/workspace-changes/tests/capture.spec.ts index 961ffe57c0..0e34b7f84e 100644 --- a/packages/deliverables/workspace-changes/tests/capture.spec.ts +++ b/packages/deliverables/workspace-changes/tests/capture.spec.ts @@ -12,7 +12,7 @@ afterEach(async () => { }) describe('captureFile', () => { - it('stores text once by content, and classifies absent, oversized, binary, and non-regular paths', async () => { + it('stores text once by content, reads no more than the cap, and classifies absent, oversized, binary, and non-regular paths', async () => { const root = await scratchDir('dsh-capture-', cleanups) const store = join(root, 'captures') await writeFile(join(root, 'a.txt'), 'one\ntwo\n') @@ -31,13 +31,16 @@ describe('captureFile', () => { expect(await captureFile(join(root, 'missing.txt'), store, 16)).toEqual({ kind: 'absent' }) expect(await captureFile(join(root, 'bin.dat'), store, 16)).toMatchObject({ kind: 'file', binary: true }) expect(await captureFile(join(root, 'dir'), store, 16)).toBeUndefined() - // Content that grew past the cap between the size check and the read is still refused. - await writeFile(join(root, 'grow.txt'), 'y'.repeat(16)) - expect(await captureFile(join(root, 'grow.txt'), store, 16)).toMatchObject({ kind: 'file' }) + // The cap is inclusive: exactly `maxBytes` is stored, one byte more is not. + await writeFile(join(root, 'edge.txt'), 'y'.repeat(16)) + expect(await captureFile(join(root, 'edge.txt'), store, 16)).toMatchObject({ kind: 'file' }) expect(sameCapture({ kind: 'absent' }, { kind: 'absent' })).toBe(true) expect(sameCapture({ kind: 'absent' }, { kind: 'oversized' })).toBe(false) expect(sameCapture(a, same)).toBe(true) expect(sameCapture(a, { kind: 'file', file: 'elsewhere', binary: false })).toBe(false) + // Unread content is never known to match: two oversized sides may differ. + expect(sameCapture({ kind: 'oversized' }, { kind: 'oversized' })).toBe(false) + expect(sameCapture({ kind: 'oversized' }, a)).toBe(false) }) }) diff --git a/packages/deliverables/workspace-changes/tests/compare.spec.ts b/packages/deliverables/workspace-changes/tests/compare.spec.ts index 15af6cef13..a2f9498d95 100644 --- a/packages/deliverables/workspace-changes/tests/compare.spec.ts +++ b/packages/deliverables/workspace-changes/tests/compare.spec.ts @@ -35,5 +35,8 @@ describe('compareText', () => { expect(result.hunks[0]).toMatchObject({ oldStart: 1, oldLines: 4000, newStart: 1, newLines: 4000 }) expect(result.hunks[0]!.lines[0]).toBe('-old 0') expect(result.hunks[0]!.lines.at(-1)).toBe('+new 3999') + const created = compareText(null, `${after}\n${before}`, 1) + expect(created.coarse).toBe(true) + expect(created).toMatchObject({ added: 8000, deleted: 0, hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 8000 }] }) }) }) diff --git a/packages/deliverables/workspace-changes/tests/git.spec.ts b/packages/deliverables/workspace-changes/tests/git.spec.ts index 71cce19d30..35982a681b 100644 --- a/packages/deliverables/workspace-changes/tests/git.spec.ts +++ b/packages/deliverables/workspace-changes/tests/git.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' -import { GitRunner, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree } from '../src/git.ts' +import { GitRunner, blobText, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, treeBlob } from '../src/git.ts' import { TurnRecorder } from '../src/recorder.ts' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { git, scratchDir, startTurn, toolCall } from './support.ts' @@ -129,6 +129,27 @@ describe('repository edge cases', () => { }) }) +describe('treeBlob and blobText', () => { + it('locates a blob by its literal path, sizes it, reads it, and reports trees and missing paths as null', async () => { + const cwd = await scratchDir('dsh-git-blob-', cleanups) + git(cwd, 'init', '-q', '-b', 'main') + await mkdir(join(cwd, 'dir')) + await writeFile(join(cwd, 'dir', 'inner.txt'), 'inner\n') + await writeFile(join(cwd, 'a[1].txt'), 'bracket\n') + await writeFile(join(cwd, 'a1.txt'), 'plain\n') + const { git: runnerGit } = await runner() + const workspace = await locateGitWorkspace(runnerGit, cwd, objectsIn(await scratchDir('dsh-git-store-', cleanups)), signal) + if (workspace === null) throw new Error('repository not located') + const tree = await snapshotTree(runnerGit, workspace, signal) + const bracket = await treeBlob(runnerGit, workspace, tree, 'a[1].txt', signal) + expect(bracket).toEqual({ oid: git(cwd, 'hash-object', 'a[1].txt').trim(), size: 8 }) + expect(await blobText(runnerGit, workspace, bracket!.oid, 64, signal)).toBe('bracket\n') + expect(await treeBlob(runnerGit, workspace, tree, 'dir', signal)).toBeNull() + expect(await treeBlob(runnerGit, workspace, tree, 'missing.txt', signal)).toBeNull() + expect(await treeBlob(runnerGit, workspace, tree, 'dir/inner.txt', signal)).toMatchObject({ size: 6 }) + }) +}) + describe('TurnRecorder', () => { it('stays silent when disposed while git work is pending, and warns on failures otherwise', async () => { const cwd = await scratchDir('dsh-recorder-', cleanups) diff --git a/packages/deliverables/workspace-changes/tests/numstat.spec.ts b/packages/deliverables/workspace-changes/tests/numstat.spec.ts index 6f5d5266770b61b73b4cb7939accbc44960e253f..b8aa34a13eb3e5777d1c03d412bbb3fabd9d3a90 100644 GIT binary patch delta 43 xcmaFN{hWJ)5hF{Cfx6~oI~K{!evB^6Ofd$NC$n6boWkrmIhNU&tyYr@hyWeo3^D)! delta 38 rcmaFP{g``$5hF8$y5?ki7Rk;2j4sTK43np_TmX{ktbRapJF5Wz&hrWz diff --git a/packages/deliverables/workspace-changes/tests/paths.spec.ts b/packages/deliverables/workspace-changes/tests/paths.spec.ts index f546db844a..699d8b90dd 100644 --- a/packages/deliverables/workspace-changes/tests/paths.spec.ts +++ b/packages/deliverables/workspace-changes/tests/paths.spec.ts @@ -1,8 +1,16 @@ -/** Display, durable, and temporary path rules. */ +/** Display, durable, canonical, and temporary path rules. */ +import { mkdir, realpath, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { compareDisplay, displayPathOf, durablePathOf, isTemporaryPath, temporaryRoots } from '../src/paths.ts' +import { afterEach, describe, expect, it } from 'vitest' +import { canonicalPath, compareDisplay, displayPathOf, durablePathOf, isTemporaryPath, temporaryRoots } from '../src/paths.ts' +import { scratchDir } from './support.ts' + +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + cleanups.length = 0 +}) const cwd = '/home/u/proj/pkg' const root = '/home/u/proj' @@ -37,6 +45,20 @@ describe('temporary paths', () => { }) }) +describe('canonicalPath', () => { + it('resolves a missing file through the nearest existing ancestor, so its spelling is stable before and after creation', async () => { + const root = await scratchDir('dsh-canonical-', cleanups) + const real = join(root, 'real') + await mkdir(join(real, 'nested'), { recursive: true }) + await symlink(real, join(root, 'link')) + const resolvedReal = await realpath(real) + expect(await canonicalPath(join(root, 'link', 'nested'))).toBe(join(resolvedReal, 'nested')) + expect(await canonicalPath(join(root, 'link', 'nested', 'new.txt'))).toBe(join(resolvedReal, 'nested', 'new.txt')) + expect(await canonicalPath(join(root, 'link', 'missing', 'deeper', 'new.txt'))).toBe(join(resolvedReal, 'missing', 'deeper', 'new.txt')) + expect(await canonicalPath('/definitely/missing/root/file')).toBe('/definitely/missing/root/file') + }) +}) + describe('compareDisplay', () => { it('orders by code units so parent and absolute paths lead', () => { const sorted = [{ display: 'src/b' }, { display: '~/x' }, { display: '../a' }, { display: '/etc/h' }, { display: 'src/a' }].sort(compareDisplay) diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index a617d12251..35c345e32e 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/packages/deliverables/workspace-changes/tests/plugin.spec.ts @@ -151,10 +151,31 @@ describe('workspace-changes in a repository', () => { expect(await diff(7)).toBeUndefined() expect(await ctx.workspaceChanges.diff(session.id, seq + 1, 0, signal)).toBeUndefined() expect(await ctx.workspaceChanges.diff(SessionId('elsewhere'), seq, 0, signal)).toBeUndefined() + // A caller's abort fails the read while the Session lives; disposal under a running read answers undefined. + const aborted = AbortSignal.abort() + await expect(ctx.workspaceChanges.diff(session.id, seq, 0, aborted)).rejects.toThrow() + await expect(ctx.workspaceChanges.diff(session.id, seq, 1, aborted)).rejects.toThrow('aborted') + const pending = ctx.workspaceChanges.diff(session.id, seq, 1, signal) ctx.emit('session/disposed', session) + expect(await pending).toBeUndefined() expect(await diff(0)).toBeUndefined() }) + it('records nothing and stays quiet about captures for a working directory that no longer exists', async () => { + const root = await scratchDir('dsh-workspace-changes-gone-', cleanups) + const cwd = join(root, 'gone') + const { ctx } = await boot() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const session = ctx.sessions.create(SessionId('gone'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + await mutate(ctx, session, 1, 'write', { file_path: 'w.txt', content: 'w\n' }, () => Promise.resolve()) + endTurn(session, 1) + await settle(ctx, session) + expect(changes(ctx, session)).toEqual([]) + expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toHaveLength(1) + }) + it('places files above the working directory and outside the repository by their display rule', async () => { const root = await repository() const cwd = join(root, 'pkg') @@ -351,33 +372,52 @@ describe('workspace-changes without a repository', () => { it('lists oversized and binary captured files without counts and serves no lines for them', async () => { const cwd = await scratchDir('dsh-workspace-changes-bounds-', cleanups) + // The recorder places its temporary directory under the platform temp root; point that root at a scratch directory. + const tempRoot = await scratchDir('dsh-workspace-changes-temp-', cleanups) + const previousTmp = process.env.TMPDIR + process.env.TMPDIR = tempRoot + cleanups.push(async () => { if (previousTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = previousTmp }) await writeFile(join(cwd, 'grows.txt'), 'small\n') + await writeFile(join(cwd, 'huge.txt'), 'a'.repeat(20)) + await writeFile(join(cwd, 'mixed.dat'), Uint8Array.of(65, 0, 66)) + await mkdir(join(cwd, 'already-dir')) const { ctx } = await boot({ maxFileBytes: 16 }) const session = ctx.sessions.create(SessionId('bounds'), { meta: { cwd } }) startTurn(session, 1) await settle(ctx, session) await mutate(ctx, session, 1, 'write', { file_path: 'grows.txt', content: 'x' }, () => writeFile(join(cwd, 'grows.txt'), 'x'.repeat(17))) + // Both sides beyond the cap are never known to match, so the file is listed rather than dropped. + await mutate(ctx, session, 1, 'write', { file_path: 'huge.txt', content: 'b' }, () => writeFile(join(cwd, 'huge.txt'), 'b'.repeat(20))) + // An oversized side outranks a binary one in the card and the tab alike. + await mutate(ctx, session, 1, 'write', { file_path: 'mixed.dat', content: 'x' }, () => writeFile(join(cwd, 'mixed.dat'), 'x'.repeat(17))) await mutate(ctx, session, 1, 'write', { file_path: 'shrinks.txt', content: 'x' }, () => writeFile(join(cwd, 'shrinks.txt'), 'x')) await mutate(ctx, session, 1, 'write', { file_path: 'bin.dat', content: 'x' }, () => writeFile(join(cwd, 'bin.dat'), Uint8Array.of(65, 0, 66))) - // A directory at the path is neither absent nor a file, so the path is not tracked. + // A directory at the path, before or after the call, is neither absent nor a file, so the path is not tracked. await mutate(ctx, session, 1, 'write', { file_path: 'dir', content: 'x' }, () => mkdir(join(cwd, 'dir'))) + await mutate(ctx, session, 1, 'write', { file_path: 'already-dir', content: 'x' }, () => Promise.resolve()) endTurn(session, 1) await settle(ctx, session) const [recorded] = changes(ctx, session) expect(recorded!.files).toEqual([ { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true }, { path: 'grows.txt', display: 'grows.txt', added: 0, deleted: 0, oversized: true }, + { path: 'huge.txt', display: 'huge.txt', added: 0, deleted: 0, oversized: true }, + { path: 'mixed.dat', display: 'mixed.dat', added: 0, deleted: 0, oversized: true }, { path: 'shrinks.txt', display: 'shrinks.txt', added: 1, deleted: 0 }, ]) const seq = announcedSeq(session) expect(await ctx.workspaceChanges.diff(session.id, seq, 0, signal)).toEqual({ kind: 'binary', path: 'bin.dat', display: 'bin.dat' }) - expect(await ctx.workspaceChanges.diff(session.id, seq, 1, signal)).toEqual({ kind: 'oversized', path: 'grows.txt', display: 'grows.txt' }) - expect(await ctx.workspaceChanges.diff(session.id, seq, 2, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+x'] }] }) + for (const index of [1, 2, 3]) { + expect(await ctx.workspaceChanges.diff(session.id, seq, index, signal)).toMatchObject({ kind: 'oversized', path: recorded!.files[index]!.path }) + } + expect(await ctx.workspaceChanges.diff(session.id, seq, 4, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+x'] }] }) // Every copy lives under the Session's temporary directory and goes with it. - const scratch = (await readdir(tmpdir())).filter(name => name.startsWith('dsh-workspace-changes-')) - expect(scratch.length).toBeGreaterThan(0) + const [scratch, ...others] = await readdir(tempRoot) + expect(others).toEqual([]) + expect(scratch).toMatch(/^dsh-workspace-changes-/) + expect((await readdir(join(tempRoot, scratch!, 'captures'))).length).toBeGreaterThan(0) ctx.emit('session/disposed', session) - await settle(ctx, session) + await vi.waitFor(async () => { expect(await readdir(tempRoot)).toEqual([]) }) }) it('drops a disposed session’s recorder and starts afresh on its next turn', async () => { From a1ac8371de7286ed9799a02cddf6698911754093 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 14:47:52 +0800 Subject: [PATCH 26/37] test(acp): refresh the fs-same-mode fixture for the write operation metadata --- snapshots/acp/fs-same-mode/session.v3.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/acp/fs-same-mode/session.v3.jsonl b/snapshots/acp/fs-same-mode/session.v3.jsonl index c05fa31b87..28376e6859 100644 --- a/snapshots/acp/fs-same-mode/session.v3.jsonl +++ b/snapshots/acp/fs-same-mode/session.v3.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Use the write tool to","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"use the current permissions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23},"stream":[{"type":"chunk","time":1789535738230,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789535738230,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," create"," a"," file"," using"," the"," write"," tool"," with"," sand","box","_per","missions","."," Let"," me"," do"," that","."]},{"type":"chunk","time":1789535738230,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789535738230,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","args":["","{","\"","file","_path","\"",": ","\"","es","cal","ated",".md","\"",", ","\"","content","\"",": ","\"","es","cal","ated","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","danger","-full","-access","\"",", ","\"","just","ification","\"",": ","\"","the"," user"," asked"," to"," escalate"," this"," write","\"","}"]},{"type":"chunk","time":1789535738231,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}},{"type":"chunk","time":1789535738231,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"use the current permissions\"}"}}},{"type":"chunk","time":1789535738231,"chunk":{"type":"usage","usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}}},{"type":"chunk","time":1789535738231,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"use the current permissions\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"operation":"create","diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20},"stream":[{"type":"chunk","time":1789535738266,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789535738266,"index":0,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created"," successfully","."," The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," single"," word"," D","ONE","."]},{"type":"chunk","time":1789535738267,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789535738267,"index":1,"dt":[0],"texts":["D","ONE"]},{"type":"chunk","time":1789535738267,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}},{"type":"chunk","time":1789535738267,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1789535738267,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}},{"type":"chunk","time":1789535738267,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From fc10bc18e018d624b2fd7da2ddddacfef492c1f1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 15:07:34 +0800 Subject: [PATCH 27/37] feat(deliverables): review one turn's changed files in a single tab The per-file comparison tab becomes a `changes-review` tab per turn: a file selector in the header lists every recorded file, a card row opens the tab on its file through a navigation parameter, and the header's tools switch between the unified and a side-by-side view, switch line wrapping, open the whole file in the Sidebar, and open it in the default application when a desktop exists. View and wrap choices are kept per tab in an exclusive store. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 2 +- .../2026-09-11-turn-changed-files-card.zh.md | 2 +- ...-09-15-changed-file-diff-preview.i18n.yaml | 4 +- .../2026-09-15-changed-file-diff-preview.md | 10 +- ...2026-09-15-changed-file-diff-preview.zh.md | 10 +- apps/web/tests/changed-files-turn.e2e.ts | 40 ++- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 18 +- packages/client/ui-deliverables/README.zh.md | 18 +- packages/client/ui-deliverables/package.json | 1 + .../client/ui-deliverables/src/changes.ts | 43 ++- .../src/client/ChangedFiles.tsx | 16 +- .../src/client/Deliverables.tsx | 10 +- .../src/client/DiffPreview.module.css | 20 -- .../src/client/DiffPreview.tsx | 157 --------- .../src/client/ReviewTab.module.css | 41 +++ .../ui-deliverables/src/client/ReviewTab.tsx | 299 ++++++++++++++++ .../src/client/diff-definition.ts | 34 -- .../ui-deliverables/src/client/index.ts | 34 +- .../ui-deliverables/src/client/locales.ts | 22 ++ .../src/client/review-definition.ts | 50 +++ .../src/client/review-store.ts | 92 +++++ .../tests/changes-diff.client.spec.ts | 63 ++++ .../tests/deliverables.client.spec.tsx | 26 +- .../tests/diff-preview.client.spec.tsx | 237 ------------- .../tests/review-tab.client.spec.tsx | 324 ++++++++++++++++++ .../ui-deliverables/tsconfig.client.json | 3 + pnpm-lock.yaml | 3 + 29 files changed, 1028 insertions(+), 559 deletions(-) delete mode 100644 packages/client/ui-deliverables/src/client/DiffPreview.module.css delete mode 100644 packages/client/ui-deliverables/src/client/DiffPreview.tsx create mode 100644 packages/client/ui-deliverables/src/client/ReviewTab.module.css create mode 100644 packages/client/ui-deliverables/src/client/ReviewTab.tsx delete mode 100644 packages/client/ui-deliverables/src/client/diff-definition.ts create mode 100644 packages/client/ui-deliverables/src/client/review-definition.ts create mode 100644 packages/client/ui-deliverables/src/client/review-store.ts create mode 100644 packages/client/ui-deliverables/tests/changes-diff.client.spec.ts delete mode 100644 packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx create mode 100644 packages/client/ui-deliverables/tests/review-tab.client.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index 98f7b1ffad..48862b591c 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: c0c5a3da59cbc82a215ef8970f8e32f65578af6f -2026-09-11-turn-changed-files-card.zh.md: 2b647f31e351a5aea8e911aee17fc7f457124301 +2026-09-11-turn-changed-files-card.md: 4b39e2b339331bbe11ee98373707e1d7310afa3c +2026-09-11-turn-changed-files-card.zh.md: c0c6f8d161c3e87ae999b1d75a6661fc2a2a8793 diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index c0c5a3da59..4b39e2b339 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -18,7 +18,7 @@ Git is the default executable on `PATH`; no environment plugin is consulted. Out Changes outside snapshot coverage come from whole-file captures: before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the named file into the Session's temporary directory, once per path per turn, and copies it again at turn end; ignored files, files outside the work tree, and every file-tool edit without a snapshot are listed from a line comparison of the two copies ([comparison decision](2026-09-15-changed-file-diff-preview.md)). Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. -The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. Each row opens the file's turn-start and turn-end comparison in the right Sidebar ([comparison decision](2026-09-15-changed-file-diff-preview.md)); with a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and the comparison tab offers the default-application open. +The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. Each row opens the turn's review tab in the right Sidebar on that file ([review decision](2026-09-15-changed-file-diff-preview.md)); with a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and the review tab offers the default-application open. The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered. The Client keeps the latest announcement per turn and reads its summary once through the authenticated summary route. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index 2b647f31e3..c0c6f8d161 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -18,7 +18,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 快照覆盖之外的改动来自整文件捕获:在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把所指文件复制到 Session 的临时目录,每轮每个路径一次,轮次结束时再复制一次;被忽略的文件、工作树之外的文件,以及没有快照时的每一次文件工具编辑,都由两份副本的逐行对比列出([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 -列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。每一行在右侧 Sidebar 打开该文件在轮次开始与结束时的对比([对比决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——对比 tab 提供用默认应用打开。 +列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。每一行在右侧 Sidebar 打开本轮的 review tab 并选中该文件([review 决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——review tab 提供用默认应用打开。 记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。 diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml index 66717906cd..310c82b3f3 100644 --- a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.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-09-15-changed-file-diff-preview.md -2026-09-15-changed-file-diff-preview.md: 06b34b33d6c83287b48b158d27039268229a111b -2026-09-15-changed-file-diff-preview.zh.md: 32d319ab5ff0da96eacac21bcd9adcb0fed4e96a +2026-09-15-changed-file-diff-preview.md: f939fe224899d8329b4a9c2d8fdc267f250403da +2026-09-15-changed-file-diff-preview.zh.md: 688bd9c841cda406f2b53825203753aaad4d6004 diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md index 06b34b33d6..f939fe2248 100644 --- a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md @@ -1,4 +1,4 @@ -# Agent Note: Changed-file comparison tab +# Agent Note: Turn review tab Status: implemented @@ -10,13 +10,13 @@ The [changed-files card](2026-09-11-turn-changed-files-card.md) tells the user w ## Decision -Each card row opens a `changes-diff` tab in the right Sidebar that compares the file at turn start and turn end. The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) recorder serves the comparison through `workspaceChanges.diff(sessionId, seq, index, signal)`; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) registers the tab type, addresses it by the viewed Session, the announcing event's sequence, and the file's index, and reads the comparison through an authenticated route. Rows open the comparison with and without a Host desktop; the tab's header offers the default-application open when a desktop exists, so the earlier row-level native open moves there and the card keeps one behavior. +Each card row opens the turn's `changes-review` tab in the right Sidebar on that file: one tab per turn, addressed by the viewed Session and the announcing event's sequence, with a file selector in its header that lists every recorded file and shows one file's turn-start and turn-end comparison at a time. The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) recorder serves each comparison through `workspaceChanges.diff(sessionId, seq, index, signal)`; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) registers the tab type, passes the row's file index as a navigation parameter, and reads the summary and the comparisons through authenticated routes. The header's tools switch between the unified view and a side-by-side view, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop, open it in the default application; view and wrap choices are kept per tab. Rows open the review with and without a desktop, so the earlier row-level native open moves into the tab and the card keeps one behavior. Files git does not cover are compared the way Codex's turn diff tracker compares its `apply_patch` edits: from whole-file copies, not from hunks. Before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder's `tools/pre-execute` step, which already waits for the baseline snapshot, copies the named file into the Session's temporary directory beside the snapshot objects, once per path per turn; at turn end it copies the path again. The copies are named by the SHA-1 of their bytes, so identical content is stored once, and they need no git. Paths the snapshots cover keep their git counts; every other captured path is listed from a line comparison of its two copies, which counts a repeatedly edited line once and includes a shell edit made after the file-tool edit. The persisted hunks and the argument-derived hunks are no longer read by the recorder. Two bounds keep the copies and the comparisons small. `maxFileBytes` caps a copy and a snapshot blob read for a comparison; a larger file is listed with `oversized`, without counts, and its comparison is refused, which is what makes whole-file copies affordable. `diffTimeoutMs` bounds the line comparison, the same 100 ms Codex uses; past it the comparison degrades to one hunk that replaces every line, marked `coarse`, so a pathological file never stalls the turn's record or the tab. Both are Config fields. -The comparison is computed when asked for, on the Host, from the two content sources kept beside the served summary: a path in a snapshot tree, read with `ls-tree -l` and `cat-file blob` under the byte cap, or a copy read from disk. Snapshot sides git reported as binary and copies holding a NUL byte serve no lines. The tab renders the hunks with old and new line numbers and no syntax highlighting; a comparison the Host no longer serves, a failed read, a binary file, and an oversized file each show one line. +The comparison is computed when asked for, on the Host, from the two content sources kept beside the served summary: a path in a snapshot tree, read with `ls-tree -l` and `cat-file blob` under the byte cap, or a copy read from disk. Snapshot sides git reported as binary and copies holding a NUL byte serve no lines. The tab renders the hunks with old and new line numbers and no syntax highlighting; the side-by-side view pairs each run of deletions with the additions that follow it row by row. A comparison the Host no longer serves, a failed read, a binary file, and an oversized file each show one line. Content still lives only as long as the Session in this Host process, as the card decision settled; the comparison shares the card's lifetime, so a conversation reopened after a Host restart has neither. @@ -30,7 +30,9 @@ Content still lives only as long as the Session in this Host process, as the car **A shadow repository for working directories outside any repository** remains deferred; the copies cover the file-tool edits there, which is the part a user can act on. -**Syntax highlighting and a side-by-side view** in the tab are deferred until the plain unified view proves insufficient. +**One tab per file**, titled by the file name, was the first implementation; reviewing a turn meant one tab per row. One tab per turn with a file selector keeps the turn's changes together and lets the row still land on its file. + +**Syntax highlighting** in the tab is deferred until the plain view proves insufficient. **Keeping rows opening the current file** would have left the comparison one click further away; the current file still opens from the prose links and the Files tab. diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md index 32d319ab5f..688bd9c841 100644 --- a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 改动文件对比 tab +# Agent Note: 轮次 review tab Status: implemented @@ -10,13 +10,13 @@ Status: implemented ## Decision -卡片的每一行在右侧 Sidebar 打开一个 `changes-diff` tab,对比该文件在轮次开始与结束时的内容。Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 记录器通过 `workspaceChanges.diff(sessionId, seq, index, signal)` 提供对比;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)注册该 tab 类型,用当前查看的 Session、宣告事件的序号和文件下标给它定址,并通过经过认证的路由读取对比。有没有 Host 桌面,行都打开对比;有桌面时 tab 头部提供用默认应用打开,先前行级别的原生打开移到那里,卡片只保留一种行为。 +卡片的每一行在右侧 Sidebar 打开本轮的 `changes-review` tab 并选中该文件:每轮一个 tab,由当前查看的 Session 和宣告事件的序号定址,头部有一个列出所有记录文件的文件选择器,一次显示一个文件在轮次开始与结束时的对比。Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 记录器通过 `workspaceChanges.diff(sessionId, seq, index, signal)` 提供每个对比;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)注册该 tab 类型,把行的文件下标作为导航参数传入,并通过经过认证的路由读取摘要和对比。头部的工具在单栏和左右视图之间切换、切换自动换行、在 Sidebar 中打开当前整个文件,有 Host 桌面时用默认应用打开它;视图与换行的选择按 tab 保留。有没有桌面,行都打开 review,先前行级别的原生打开移进 tab,卡片只保留一种行为。 git 覆盖不到的文件按 Codex 的 turn diff tracker 对比其 `apply_patch` 编辑的方式对比:用整文件副本,不用 hunk。在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器本来就要等待基线快照的 `tools/pre-execute` 步骤把所指文件复制到 Session 临时目录里快照对象旁边,每轮每个路径一次;轮次结束时再复制一次该路径。副本按其字节的 SHA-1 命名,相同内容只存一份,且不需要 git。快照覆盖到的路径保留 git 的行数;其余每个被捕获的路径由两份副本的逐行对比列出,反复编辑的行只计一次,文件工具编辑之后的 shell 改动也包含在内。记录器不再读取持久化的 hunk 和由参数推出的 hunk。 两个上限让副本和对比保持小。`maxFileBytes` 限制副本和为对比而读出的快照 blob;更大的文件列出时带 `oversized`,没有行数,其对比被拒绝,整文件副本因此负担得起。`diffTimeoutMs` 限制逐行对比,与 Codex 一样是 100 毫秒;超时后退化为一个替换全部行的 hunk,标记 `coarse`,因此病态的文件从不会拖住本轮记录或 tab。两者都是 Config 字段。 -对比在被请求时在 Host 上计算,来源是保存在所提供摘要旁边的两侧内容来源:快照树中的路径,用 `ls-tree -l` 和 `cat-file blob` 在字节上限之内读出;或者从磁盘读取的副本。git 报告为二进制的快照一侧和含 NUL 字节的副本不提供行。tab 渲染带旧新行号的 hunk,没有语法高亮;Host 已不再提供的对比、读取失败、二进制文件和过大的文件各显示一行。 +对比在被请求时在 Host 上计算,来源是保存在所提供摘要旁边的两侧内容来源:快照树中的路径,用 `ls-tree -l` 和 `cat-file blob` 在字节上限之内读出;或者从磁盘读取的副本。git 报告为二进制的快照一侧和含 NUL 字节的副本不提供行。tab 渲染带旧新行号的 hunk,没有语法高亮;左右视图把每一段删除与紧随其后的新增逐行配对。Host 已不再提供的对比、读取失败、二进制文件和过大的文件各显示一行。 内容仍然只在本 Host 进程内随 Session 存活,这是卡片决定已经定下的;对比与卡片同寿命,因此 Host 重启后重新打开的对话两者都没有。 @@ -30,7 +30,9 @@ git 覆盖不到的文件按 Codex 的 turn diff tracker 对比其 `apply_patch` **给仓库之外的工作目录建影子仓库**仍然推迟;副本已经覆盖那里的文件工具编辑,这是用户能采取行动的部分。 -**tab 里的语法高亮和左右对照视图**推迟到纯 unified 视图被证明不够用时再做。 +**每个文件一个 tab**、以文件名作标题,是第一版实现;审阅一轮意味着每行一个 tab。每轮一个 tab 加文件选择器把一轮的改动放在一起,行仍然能落到自己的文件上。 + +**tab 里的语法高亮**推迟到纯文本视图被证明不够用时再做。 **保留行打开当前文件**会让对比多一次点击才能到达;当前文件仍可从正文链接和文件 tab 打开。 diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 411b250ecb..6084e407e7 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -1,4 +1,4 @@ -/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card; its rows open comparisons. */ +/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card; its rows open the review. */ import { execFileSync } from 'node:child_process' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -117,24 +117,34 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(tripwire.warnings).toEqual([]) }) - it('opens a shell-appended file’s comparison from the snapshots and an ignored file’s from its captured copies', async () => { + it('reviews a shell-appended file from the snapshots and an ignored file from its captured copies in one tab', async () => { const card = page.locator('[data-changed-files]') const column = page.locator('[data-rightbar-col]') + const drawn = (root: ReturnType) => + root.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`)) await card.getByRole('button', { name: '查看 notes.txt 的改动' }).click() - const notes = column.locator('[data-changes-diff][data-diff-state="text"]') - await notes.waitFor({ state: 'visible' }) - expect(await column.locator('[data-dockkit-tab]').filter({ hasText: 'notes.txt' }).count()).toBe(1) - expect(await notes.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`))).toEqual([ - 'context:11 start', 'add:2+done', - ]) + const review = column.locator('[data-changes-review]') + await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' }) + expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1) + await expect.poll(() => drawn(review)).toEqual(['context:11 start', 'add:2+done']) // The ignored file has no snapshot; its comparison comes from the copies captured around the write call. - await card.getByRole('button', { name: '查看 app.local 的改动' }).click() - const local = column.locator('[data-changes-diff][data-diff-state="text"]').filter({ hasText: 'mode=demo' }) - await local.waitFor({ state: 'visible' }) - expect(await local.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`))).toEqual(['add:1+mode=demo']) - expect(await local.getByText('本轮新建的文件').count()).toBe(1) - // No desktop, so the header offers no native open. - expect(await local.getByRole('button').count()).toBe(0) + await review.getByRole('button', { name: '选择要查看的文件' }).click() + await page.getByRole('menuitem').filter({ hasText: 'app.local' }).click() + await review.locator('[data-review-file="app.local"]').waitFor({ state: 'visible' }) + await expect.poll(() => drawn(review)).toEqual(['add:1+mode=demo']) + expect(await review.getByText('本轮新建的文件').count()).toBe(1) + // A card row opens the same tab on another file; the split and wrap choices switch the drawing. + await card.getByRole('button', { name: '查看 intro.md 的改动' }).click() + await review.locator('[data-review-file="intro.md"]').waitFor({ state: 'visible' }) + expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1) + await review.getByRole('button', { name: '左右对比' }).click() + await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' }) + await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。']) + await review.getByRole('button', { name: '自动换行' }).click() + expect(await review.locator('[data-review-view][data-review-wrap]').count()).toBe(1) + // No desktop, so the tools offer the sidebar file but no native open. + expect(await review.locator('[data-review-tool="open-file"]').count()).toBe(1) + expect(await review.locator('[data-review-tool="open-native"]').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 3999f93f9e..08eeb2a50d 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/ui-deliverables/README.md -README.md: 12c5805ff78ab8d27c6be409cda6d62a8a78e74f -README.zh.md: a23c503ae18583bb3109e877adbc4c3f17e1d7c3 +README.md: ab9e25c7b351a8eb82461543654c28155945a63b +README.zh.md: 194d92305fe30e1836838b3af73172b00608a1a6 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 12c5805ff7..ab9e25c7b3 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -1,5 +1,5 @@ --- -description: "Changed files, deliveries, and clickable file references for the Web GUI: the changed-files card and delivery cards a finished turn ends with, the comparison tab each changed file opens, and inline-code links in the closing prose; for users and maintainers of the deliverables experience." +description: "Changed files, deliveries, and clickable file references for the Web GUI: the changed-files card and delivery cards a finished turn ends with, the review tab that compares each changed file, and inline-code links in the closing prose; for users and maintainers of the deliverables experience." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders the changed-files card a finished turn ends with — the files the turn changed, with the Host's line counts, each opening a Sidebar tab comparing the file at turn start and turn end — plus cards for explicitly delivered files, and links matching inline-code references in the closing prose so a mentioned file opens in the right Sidebar. Listed and linked paths come from the recorded summary, successful mutations, and explicit deliveries, never from the prose. Only the shipped Web patch loads this package; removing its cordis.yml entry removes the guidance, cards, and prose links together. +This package renders the changed-files card a finished turn ends with — the files the turn changed, with the Host's line counts, each opening the turn's review tab on that file — plus cards for explicitly delivered files, and links matching inline-code references in the closing prose so a mentioned file opens in the right Sidebar. Listed and linked paths come from the recorded summary, successful mutations, and explicit deliveries, never from the prose. Only the shipped Web patch loads this package; removing its cordis.yml entry removes the guidance, cards, and prose links together. ## Table of Contents @@ -36,11 +36,11 @@ The `present` tool row shows running, delivered, failed, or interrupted status; ### The changed-files card -The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens its file's comparison in the right Sidebar. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager through an authenticated Host action that shows the delivery cards' pending and retryable-error states in place of the totals; a completed open shows the totals again. Without a desktop the header is a plain label. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. +The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager through an authenticated Host action that shows the delivery cards' pending and retryable-error states in place of the totals; a completed open shows the totals again. Without a desktop the header is a plain label. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. -### The comparison tab +### The review tab -A row opens a `changes-diff` tab addressed by the viewed Session, the announcing event's sequence, and the file's index, titled with the file name; opening the same row again reveals the existing tab. The tab reads the comparison once through the authenticated comparison route and shows the file's path in its header. A text comparison lists its hunks with the old and new line numbers of every line, additions and deletions in the success and error colours, and a note when the file was created or deleted in the turn, when both sides hold the same lines, or when the Host's line comparison timed out and every line shows as replaced. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. With a Host desktop available the header offers opening the file in the default application, with the same pending and retryable-error states as the cards. The comparison is the turn's snapshot of the file, not its current content; the current content opens from the prose links or the Files tab. +A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison; the selected file's counts follow it. The header's tools switch between the unified view and a side-by-side view that pairs each run of deletions with the additions that follow it, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, additions and deletions in the success and error colours, and a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. ### Inline-code links @@ -54,9 +54,9 @@ The closing prose links produced or delivered paths: an inline-code token resolv
    Implementation internals — click to expand -The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-diff/session////` through `ctx.sidebarRight.openResource`; the package registers the `changes-diff` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat, which reads the comparison through the authenticated comparison route into a store cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty chain at zero cost. +The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session///` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory, and the comparison tab's native open uses the same indexed coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the comparison tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory, and the comparison tab's native open uses the same indexed coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the review tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
    @@ -103,8 +103,8 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. -- **Folder and native opening need a Host desktop** — without one the card header is inert and the comparison tab offers no native open; the comparison itself needs only the Host that recorded the turn. -- **Comparisons are unhighlighted unified diffs** — the tab shows plain hunks without syntax colouring or a side-by-side view, and draws at most 5,000 lines before saying so. +- **Folder and native opening need a Host desktop** — without one the card header is inert and the review tab offers no native open; the comparison itself needs only the Host that recorded the turn. +- **Comparisons are unhighlighted** — the tab shows plain hunks without syntax colouring, and draws at most 5,000 lines before saying so. - **Comparisons carry whole file text** — the comparison route serves a listed file's complete text wherever the Host recorded it, including ignored files and files outside the workspace root the Sidebar previews are confined to. - **Package-local header glyph** — the card's angle-bracket mark lives in `src/client/icons.tsx` until the shared icon set carries it; its props already match the shared icon contract. - **Files outside the workspace open by absolute path only** — the recorded path is the Host path at recording time; a moved workspace or a different viewing Session cannot relocate it. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index a23c503ae1..194d92305f 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的改动文件、交付文件与可点击文件引用:已完成轮次末尾的改动文件卡片与交付文件卡片、每个改动文件打开的对比 tab,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。" +description: "Web GUI 的改动文件、交付文件与可点击文件引用:已完成轮次末尾的改动文件卡片与交付文件卡片、逐个对比改动文件的 review tab,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包渲染已完成轮次末尾的改动文件卡片——列出本轮改动的文件及 Host 记录的行数,每一行打开 Sidebar 中对比该文件在轮次开始与结束时内容的 tab——以及显式交付文件的卡片,并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。列出与链接的路径来自记录的改动摘要、成功的文件修改与显式交付,而非收尾正文。只有正式提供的 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、卡片与正文链接。 +本包渲染已完成轮次末尾的改动文件卡片——列出本轮改动的文件及 Host 记录的行数,每一行在该文件上打开本轮的 review tab——以及显式交付文件的卡片,并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。列出与链接的路径来自记录的改动摘要、成功的文件修改与显式交付,而非收尾正文。只有正式提供的 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、卡片与正文链接。 ## 目录 @@ -36,11 +36,11 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 ### 改动文件卡片 -卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开该文件的对比。Host 有桌面时,标题通过经过认证的 Host 操作在文件管理器中打开包含所列文件的最深工作区文件夹,并在合计位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示合计。没有桌面时标题是普通标签。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 +卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件。Host 有桌面时,标题通过经过认证的 Host 操作在文件管理器中打开包含所列文件的最深工作区文件夹,并在合计位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示合计。没有桌面时标题是普通标签。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 -### 对比 tab +### review tab -行打开一个 `changes-diff` tab,其地址由当前查看的 Session、宣告事件的序号和文件下标组成,以文件名作标题;再次点击同一行会显示已有的 tab。tab 通过经过认证的对比路由读取一次对比,并在头部显示文件路径。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,新增与删除分别用成功色和错误色,并在文件是本轮新建或删除、两侧内容相同,或 Host 的逐行对比超时而按整文件替换显示时给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。Host 有桌面时,头部提供用默认应用打开该文件,等待与可重试错误状态与卡片一致。对比是本轮对该文件的快照,不是它当前的内容;当前内容可从正文链接或文件 tab 打开。 +行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比;所选文件的行数跟在后面。头部的工具在单栏视图和左右视图之间切换,后者把每一段删除与紧随其后的新增逐行配对;切换自动换行;在 Sidebar 中打开当前整个文件;Host 有桌面时用默认应用打开它,等待与可重试错误状态与卡片一致。视图与换行的选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,新增与删除分别用成功色和错误色,并在文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 ### 行内代码链接 @@ -54,9 +54,9 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
    实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-diff/session////`;本包在 `builtin` 档为该模式注册 `changes-diff` tab 类型,并把其 body 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的对比路由把对比读进一个连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空链以零成本留下。 +Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session///`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录;对比 tab 的原生打开使用同一组带下标的坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后对比 tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录;对比 tab 的原生打开使用同一组带下标的坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
    @@ -103,8 +103,8 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 -- **打开文件夹和原生打开需要 Host 桌面**——没有桌面时卡片标题不可点击,对比 tab 也不提供原生打开;对比本身只需要记录了该轮的 Host。 -- **对比是没有高亮的 unified diff**——tab 只显示纯文本 hunk,没有语法着色,也没有左右对照视图,最多绘制 5000 行并给出提示。 +- **打开文件夹和原生打开需要 Host 桌面**——没有桌面时卡片标题不可点击,review tab 也不提供原生打开;对比本身只需要记录了该轮的 Host。 +- **对比没有语法高亮**——tab 只显示纯文本 hunk,最多绘制 5000 行并给出提示。 - **对比携带整个文件的文本**——对比路由会送出所列文件在 Host 上记录时的完整文本,包括被忽略的文件和 Sidebar 预览所限定的工作区根目录之外的文件。 - **包内的标题字形**——卡片的尖括号标记放在 `src/client/icons.tsx` 中,直到共享图标集收录它;其 props 已与共享图标契约一致。 - **工作区外的文件只按绝对路径打开**——记录的路径是记录时的 Host 路径;工作区移动或换一个查看 Session 都无法重新定位它。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index c9a1cfdb00..28ed49b7fe 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts index b6773dcebd..450a7ea29a 100644 --- a/packages/client/ui-deliverables/src/changes.ts +++ b/packages/client/ui-deliverables/src/changes.ts @@ -11,8 +11,8 @@ export const CHANGES_DIFF_PATH = '/api/changes.diff' /** Authenticated POST route for opening a changed file, or the changed files' common folder, on the Host desktop. */ export const CHANGES_OPEN_PATH = '/api/changes.open' -/** Resource-address prefix of a changed file's comparison tab in the right Sidebar. */ -export const CHANGES_DIFF_ADDRESS = 'dsh-resource://changes-diff/session/' +/** Resource-address prefix of a turn's review tab in the right Sidebar. */ +export const CHANGES_REVIEW_ADDRESS = 'dsh-resource://changes-review/session/' /** The summary fields the route serves; the Host keeps the working directory and snapshot ids to itself. */ export type ChangesSummary = Pick @@ -20,13 +20,12 @@ export type ChangesSummary = Pick { @@ -126,30 +125,28 @@ export function changedFileUrl(sessionId: SessionId, seq: number, index: number } /** - * The right-Sidebar address of one listed file's comparison. The display path - * rides the address only for the tab title; the coordinates identify the content. - * @param coordinates - viewed Session, announcing event, file index, and display path. - * @returns a `dsh-resource://changes-diff/session/…` address. + * The right-Sidebar address of one turn's review. The Session and the event + * sequence identify the content; the turn rides along for the tab title. + * @param coordinates - viewed Session, announcing event, and turn. + * @returns a `dsh-resource://changes-review/session/…` address. */ -export function changesDiffAddress({ sessionId, seq, index, display }: ChangesDiffCoordinates): string { - return `${CHANGES_DIFF_ADDRESS}${encodeURIComponent(sessionId)}/${seq}/${index}/${encodeURIComponent(display)}` +export function changesReviewAddress({ sessionId, seq, turn }: ChangesReviewCoordinates): string { + return `${CHANGES_REVIEW_ADDRESS}${encodeURIComponent(sessionId)}/${seq}/${turn}` } /** - * Read the coordinates back out of a comparison address. + * Read the coordinates back out of a review address. * @param address - a resource address. * @returns the coordinates, or undefined for any other address. */ -export function parseChangesDiffAddress(address: string): ChangesDiffCoordinates | undefined { - if (!address.startsWith(CHANGES_DIFF_ADDRESS)) return undefined - const parts = address.slice(CHANGES_DIFF_ADDRESS.length).split('/') - if (parts.length !== 4) return undefined - const [sessionId, seq, index, display] = parts as [string, string, string, string] - if (sessionId === '' || display === '' || !/^\d+$/.test(seq) || !/^\d+$/.test(index)) return undefined +export function parseChangesReviewAddress(address: string): ChangesReviewCoordinates | undefined { + if (!address.startsWith(CHANGES_REVIEW_ADDRESS)) return undefined + const parts = address.slice(CHANGES_REVIEW_ADDRESS.length).split('/') + if (parts.length !== 3) return undefined + const [sessionId, seq, turn] = parts as [string, string, string] + if (sessionId === '' || !/^\d+$/.test(seq) || !/^[1-9]\d*$/.test(turn)) return undefined try { - return { - sessionId: decodeURIComponent(sessionId) as SessionId, seq: Number(seq), index: Number(index), display: decodeURIComponent(display), - } + return { sessionId: decodeURIComponent(sessionId) as SessionId, seq: Number(seq), turn: Number(turn) } } catch { // A malformed percent sequence is not an address this package minted. return undefined diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 1eb2d9bf47..fe7be9747d 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,4 +1,4 @@ -/** The changed-files card: a folder-opening header, per-file line counts opening each file's comparison, and a three-row fold. */ +/** The changed-files card: a folder-opening header, per-file line counts opening the turn's review on that file, and a three-row fold. */ import { useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -39,13 +39,13 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop } /** - * Render one turn's changed files. Each row opens its file's turn-start and - * turn-end comparison in the right Sidebar; the header opens the files' common - * folder only with a Host desktop. + * Render one turn's changed files. Each row opens the turn's review in the + * right Sidebar on that file; the header opens the files' common folder only + * with a Host desktop. * @param props - the recorded summary, Host capabilities, folder gesture status, openers, and localized copy. * @returns the card. */ -export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openDiff, t }: { +export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openReview, t }: { /** The served summary with the sequence of the event that announced it. */ changes: Pick & { seq: number } cwd: string | undefined @@ -53,8 +53,8 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op host: PresentedHost | null phases: Record onOpen: (index: number | null) => void - /** Open the comparison of the file at an original summary index. */ - openDiff: (index: number, display: string) => void + /** Open the turn's review on the file at an original summary index. */ + openReview: (index: number) => void } & PropsLocale) { const [expanded, setExpanded] = useState(false) const native = host !== null && host.available @@ -82,7 +82,7 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op
  • } -
  • - {(state === undefined || state === 'loading') &&

    {t('diff.loading')}

    } - {state === 'missing' &&

    {t('diff.missing')}

    } - {state === 'error' &&
    - {t('diff.error')} - -
    } - {typeof state === 'object' && state.kind === 'binary' &&

    {t('diff.binary')}

    } - {typeof state === 'object' && state.kind === 'oversized' &&

    {t('diff.oversized')}

    } - {typeof state === 'object' && state.kind === 'text' && } - - ) -} - -/** - * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total. - * @param hunks - served hunks. - * @returns the hunks with the last one shortened as needed, and whether anything was cut. - */ -export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } { - let budget = MAX_RENDERED_LINES - const kept: WorkspaceDiffHunk[] = [] - for (const hunk of hunks) { - if (budget === 0) return { hunks: kept, truncated: true } - kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) }) - budget -= Math.min(budget, hunk.lines.length) - } - return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) } -} - -/** The hunks of a text comparison with their line numbers. */ -function TextDiff({ diff, t }: { diff: Extract } & PropsLocale): ReactNode { - const note = noteOf(diff) - const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks]) - return ( -
    - {note !== undefined &&

    {t(note)}

    } - {diff.coarse &&

    {t('diff.coarse')}

    } - {truncated &&

    {t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

    } - {hunks.map((hunk, position) => ( -
    -
    {`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`}
    - {hunkRows(hunk).map((row, at) => ( -
    - {row.old ?? ''} - {row.new ?? ''} - {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} - {row.text} -
    - ))} -
    - ))} -
    - ) -} diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css new file mode 100644 index 0000000000..1e116e1ed2 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -0,0 +1,41 @@ +/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. */ +.root { display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } +.header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } +.selector { flex: 0 1 auto; min-width: 0; } +.selectorLabel { flex: 1 1 auto; min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; } +.selectorButton:hover, .selectorButton[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } +.selectorText { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item { display: flex; gap: 12px; align-items: center; justify-content: space-between; min-width: 0; } +.itemPath { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.itemCounts, .counts { display: inline-flex; flex: none; gap: 6px; font-family: var(--ds-font-family-code); font-size: 12px; color: var(--dsw-alias-label-tertiary); } +.counts { min-width: 0; margin-right: auto; } +.added { color: var(--dsw-alias-state-success-primary); } +.deleted { color: var(--dsw-alias-state-error-primary); } +.label { color: var(--dsw-alias-label-tertiary); } +.tools { display: inline-flex; flex: none; gap: 2px; align-items: center; margin-left: auto; } +.tool { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 6px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; } +.tool svg { width: 15px; height: 15px; } +.tool:hover:not(:disabled), .tool[aria-pressed='true'] { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } +.tool:disabled { cursor: progress; } +.tool[data-error] { color: var(--dsw-alias-state-error-primary); } +.status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } +.body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } +.note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); } +.hunk { margin-bottom: 8px; } +.hunkHeader { padding: 4px 16px; color: var(--dsw-alias-label-tertiary); white-space: pre; } +.line { display: grid; grid-template-columns: 3.5em 3.5em 1.2em minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; } +.splitLine { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; } +.cell { display: grid; grid-template-columns: 3.5em minmax(0, 1fr); min-width: 0; } +.cell + .cell { border-left: 0.5px solid var(--dsw-alias-border-l3); } +.number { padding-right: 8px; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; } +.sign { text-align: center; user-select: none; } +.text { padding-right: 16px; } +.body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; } +.body[data-review-wrap] .text { overflow-wrap: anywhere; } +.add { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); } +.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); } +.del { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); } +.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); } +.context .text { color: var(--dsw-alias-label-secondary); } +.empty { background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx new file mode 100644 index 0000000000..935a66f2bb --- /dev/null +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -0,0 +1,299 @@ +/** + * The review tab: one turn's changed files behind a file selector, with the + * selected file's turn-start and turn-end comparison drawn unified or side by + * side, wrapped or scrolling, and controls to open the file itself. + */ +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { + Button, IconChevronDownOutline14, IconCodeOutline16, IconPanelLeftOutline16, IconRightUpOutline16, IconWrapLinesOutline16, Menu, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' +import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types' +import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress, type ChangesDiff } from '../changes.ts' +import type { ChangesDiffStore } from './changes-diff.ts' +import type { ChangesSummaryStore } from './changes-summary.ts' +import type { PresentedOpenController } from './present-open.ts' +import type { ChangesReviewParams } from './review-definition.ts' +import type { createReviewStore } from './review-store.ts' +import type { NS } from './locales.ts' +import css from './ReviewTab.module.css' + +/** Lines drawn before the tab stops; a coarse comparison of a file near the byte cap would otherwise draw every line. */ +export const MAX_RENDERED_LINES = 5000 + +const GROUPED = new Intl.NumberFormat('en-US') + +/** Summary and comparison reads, desktop metadata, and the native open supplied by the plugin. */ +export interface ReviewInjected { + hooks: { + changesSummary: ObservableSnapshot> + changesDiff: ObservableSnapshot> + presentedOpen: ObservableSnapshot> + presentedHost: ObservableSnapshot> + } + loadChangesSummary: ChangesSummaryStore['load'] + loadChangesDiff: ChangesDiffStore['load'] + reloadPresentedHost: PresentedOpenController['loadHost'] + openChanged: PresentedOpenController['openChanged'] +} + +/** The body's composed props: the tab it draws, its store, its injected face, and its copy. */ +export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore> + & InjectFace & PropsLocale + +/** One drawn line of a hunk with its line numbers on each side. */ +export interface DiffRow { + kind: 'add' | 'del' | 'context' + old: number | undefined + new: number | undefined + text: string +} + +/** One side-by-side row: the old side, the new side, or both. */ +export interface SplitRow { + left?: { no: number; text: string; kind: 'del' | 'context' } + right?: { no: number; text: string; kind: 'add' | 'context' } +} + +/** + * Number a hunk's lines: context lines count on both sides, deletions on the + * old side, additions on the new side. + * @param hunk - a served hunk. + * @returns the rows in order. + */ +export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] { + let oldNo = hunk.oldStart + let newNo = hunk.newStart + return hunk.lines.map((line) => { + const text = line.slice(1) + switch (line[0]) { + case '+': return { kind: 'add', old: undefined, new: newNo++, text } + case '-': return { kind: 'del', old: oldNo++, new: undefined, text } + default: return { kind: 'context', old: oldNo++, new: newNo++, text } + } + }) +} + +/** + * Pair a hunk's lines for the side-by-side view: each run of deletions is + * aligned with the run of additions that follows it, row by row, and context + * lines sit on both sides. + * @param hunk - a served hunk. + * @returns the rows in order. + */ +export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] { + const rows: SplitRow[] = [] + let dels: NonNullable[] = [] + let adds: NonNullable[] = [] + const flush = (): void => { + for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) { + const left = dels[at] + const right = adds[at] + rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } }) + } + dels = [] + adds = [] + } + for (const row of hunkRows(hunk)) { + if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' }) + else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' }) + else { + flush() + rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } }) + } + } + flush() + return rows +} + +/** + * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total. + * @param hunks - served hunks. + * @returns the hunks with the last one shortened as needed, and whether anything was cut. + */ +export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } { + let budget = MAX_RENDERED_LINES + const kept: WorkspaceDiffHunk[] = [] + for (const hunk of hunks) { + if (budget === 0) return { hunks: kept, truncated: true } + kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) }) + budget -= Math.min(budget, hunk.lines.length) + } + return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) } +} + +/** The one-line fact about a text comparison worth stating above its hunks, if any. */ +function noteOf(diff: Extract): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined { + if (!diff.before) return 'diff.created' + if (!diff.after) return 'diff.deleted' + if (diff.hunks.length === 0) return 'diff.unchanged' + return undefined +} + +/** The file index a navigation names, when it names one. */ +function navigatedIndex(params: unknown): number | undefined { + const index = (params as ChangesReviewParams | undefined)?.index + return typeof index === 'number' && Number.isSafeInteger(index) && index >= 0 ? index : undefined +} + +/** Added and deleted line counts in the card's colors. */ +function Counts({ file, t }: { file: WorkspaceChangedFile } & PropsLocale): ReactNode { + if (file.binary === true) return {t('changes.binary')} + if (file.oversized === true) return {t('changes.oversized')} + return <> + {t('changes.added', { count: GROUPED.format(file.added) })} + {t('changes.deleted', { count: GROUPED.format(file.deleted) })} + +} + +/** + * The review type's body, registered under `sidebar.right.pane.tab` as `changes-review`. + * @param props - composed slot props. + * @returns the selected file's comparison behind the file selector, or the state that stands in for it. + */ +export function ReviewTab({ + useTabInfo, sessionId, useSessions, useStore, actions, useChangesSummary, useChangesDiff, usePresentedOpen, usePresentedHost, + loadChangesSummary, loadChangesDiff, reloadPresentedHost, openChanged, t, +}: ReviewTabProps): ReactNode { + const { tab } = useTabInfo() + const { navigation, signal } = tab + const coordinates = useMemo(() => parseChangesReviewAddress(tab.contentId), [tab.contentId]) + if (coordinates === undefined) throw new Error(`ui-deliverables: not a review address "${tab.contentId}"`) + const { seq } = coordinates + const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd) + const summary = useChangesSummary(value => value[changesSummaryUrl(sessionId, seq)]) + const state = useStore(store => store.byTab[tab.id]) + const host = usePresentedHost(value => value) + // Every navigation to this tab applies its file index once; the first one seeds the tab's choices. + useEffect(() => { + if (state?.navigated === navigation.revision) return + actions.navigated(tab.id, navigation.revision, navigatedIndex(navigation.params) ?? state?.index ?? 0) + }, [state, navigation.revision, navigation.params, actions, tab.id]) + useEffect(() => { + const forget = (): void => { actions.forget(tab.id) } + signal.addEventListener('abort', forget, { once: true }) + return () => { signal.removeEventListener('abort', forget) } + }, [signal, actions, tab.id]) + useEffect(() => { + if (summary === undefined) void loadChangesSummary(sessionId, seq) + }, [summary, sessionId, seq, loadChangesSummary]) + useEffect(() => { + if (host === null) void reloadPresentedHost() + }, [host, reloadPresentedHost]) + const files = typeof summary === 'object' ? summary.files : [] + // A navigated index the summary does not list falls back to the first file. + const index = state !== undefined && files[state.index] !== undefined ? state.index : 0 + const file = files[index] + const diffState = useChangesDiff(value => file === undefined ? undefined : value[changesDiffUrl(sessionId, seq, index)]) + useEffect(() => { + if (file !== undefined && diffState === undefined) void loadChangesDiff(sessionId, seq, index) + }, [file, diffState, sessionId, seq, index, loadChangesDiff]) + const phase = usePresentedOpen(value => file === undefined ? undefined : value[changedFileUrl(sessionId, seq, index)]) + const [menuOpen, setMenuOpen] = useState(false) + const split = state?.split === true + const wrap = state?.wrap === true + const native = host !== null && host !== 'error' && host.available && phase !== 'nativeUnavailable' + const summaryState = summary === undefined || summary === 'loading' ? 'loading' : summary === 'missing' ? 'missing' : 'ready' + return ( +
    +
    + {file === undefined + ? {t('review.title', { turn: String(coordinates.turn) })} + : { setMenuOpen(false) }} + anchor={} + items={files.map((entry, at) => ({ id: String(at), label: + {entry.display} + + }))} + selectedId={String(index)} + onSelect={(id) => { actions.selected(tab.id, Number(id)); setMenuOpen(false) }} />} + {file !== undefined && } + + + + + + + + {file !== undefined && + + } + {file !== undefined && native && + + } + +
    + {summaryState === 'loading' &&

    {t('diff.loading')}

    } + {summaryState === 'missing' &&

    {t('diff.missing')}

    } + {file !== undefined && { void loadChangesDiff(sessionId, seq, index) }} />} +
    + ) +} + +/** The selected file's comparison, or the state that stands in for it. */ +function FileBody({ state, split, wrap, retry, t }: { + state: ChangesDiffStore['state'] extends { getSnapshot(): infer S } ? (S extends Record ? V : never) : never + split: boolean + wrap: boolean + retry: () => void +} & PropsLocale): ReactNode { + if (state === undefined || state === 'loading') return

    {t('diff.loading')}

    + if (state === 'missing') return

    {t('diff.missing')}

    + if (state === 'error') { + return
    {t('diff.error')}
    + } + if (state.kind === 'binary') return

    {t('diff.binary')}

    + if (state.kind === 'oversized') return

    {t('diff.oversized')}

    + return +} + +/** The hunks of a text comparison with their line numbers, unified or side by side. */ +function TextDiff({ diff, split, wrap, t }: { diff: Extract; split: boolean; wrap: boolean } & PropsLocale): ReactNode { + const note = noteOf(diff) + const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks]) + return ( +
    + {note !== undefined &&

    {t(note)}

    } + {diff.coarse &&

    {t('diff.coarse')}

    } + {truncated &&

    {t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

    } + {hunks.map((hunk, position) => ( +
    +
    {`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`}
    + {split ? splitRows(hunk).map((row, at) => ( +
    + + {row.left?.no ?? ''} + {row.left?.text ?? ''} + + + {row.right?.no ?? ''} + {row.right?.text ?? ''} + +
    + )) : hunkRows(hunk).map((row, at) => ( +
    + {row.old ?? ''} + {row.new ?? ''} + {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} + {row.text} +
    + ))} +
    + ))} +
    + ) +} diff --git a/packages/client/ui-deliverables/src/client/diff-definition.ts b/packages/client/ui-deliverables/src/client/diff-definition.ts deleted file mode 100644 index 97eab9ce9f..0000000000 --- a/packages/client/ui-deliverables/src/client/diff-definition.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * What the `changes-diff` tab type IS: the right-Sidebar viewer of one listed - * changed file's turn-start and turn-end comparison. It claims the - * `dsh-resource://changes-diff/session/…` addresses the changed-files card - * mints; the coordinates in the address identify the content, and the display - * path they carry names the tab. - */ -import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' -import { parseChangesDiffAddress } from '../changes.ts' -import { basename } from '../presented.ts' - -/** The tab kind this package owns. */ -export const CHANGES_DIFF_KIND = 'changes-diff' - -/** This implementation's identity in the tab system, and the key its body registers under. */ -export const CHANGES_DIFF_ID = '@deepseek-ai/dsh-client-ui-deliverables' - -/** - * The comparison type's registry definition. - * @returns the definition to register. - */ -export function changesDiffDefinition(): SidebarRightTabDefinition { - return { - id: CHANGES_DIFF_ID, - kind: CHANGES_DIFF_KIND, - patterns: ['dsh-resource://changes-diff/**'], - priority: 'builtin', - canOpen: address => parseChangesDiffAddress(address) !== undefined, - title: (address) => { - const display = parseChangesDiffAddress(address)?.display - return display === undefined ? address : basename(display) - }, - } -} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index c7a3121bb1..c9167d2605 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -1,8 +1,8 @@ /** * Deliverables plugin, browser half: registers the changed-files card and - * delivery cards into the chat view's turn-tail chain, the `changes-diff` - * right-Sidebar tab type that shows one listed file's turn-start and turn-end - * comparison, and provides the `chatFileMentions` service that links + * delivery cards into the chat view's turn-tail chain, the `changes-review` + * right-Sidebar tab type that reviews one turn's changed files one comparison + * at a time, and provides the `chatFileMentions` service that links * inline-code mentions of produced or delivered files in the closing prose. * All policy lives here — the supported mutation calls, mention matching, row * cap, and copy — so composing this plugin out of cordis.yml removes every @@ -16,14 +16,15 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' -import { changesDiffAddress } from '../changes.ts' +import { changesReviewAddress } from '../changes.ts' import { ChangesDiffStore } from './changes-diff.ts' import { ChangesSummaryStore } from './changes-summary.ts' import { PresentedOpenController } from './present-open.ts' import { PresentRow } from './PresentRow.tsx' import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx' -import { DiffPreview, type DiffPreviewInjected } from './DiffPreview.tsx' -import { CHANGES_DIFF_ID, changesDiffDefinition } from './diff-definition.ts' +import { ReviewTab, type ReviewInjected } from './ReviewTab.tsx' +import { CHANGES_REVIEW_ID, changesReviewDefinition } from './review-definition.ts' +import { createReviewStore } from './review-store.ts' import { en, NS, zh, type DeliverablesKey } from './locales.ts' import { deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles, @@ -31,7 +32,7 @@ import { declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Changed-files card, comparison tab, delivery card, and file-mention copy. */ + /** Changed-files card, review tab, delivery card, and file-mention copy. */ 'deliverables': DeliverablesKey } } @@ -67,29 +68,32 @@ export function apply(ctx: ClientContext): void { loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), - openChangesDiff: (coordinates) => { ctx.sidebarRight.openResource(changesDiffAddress(coordinates)) }, + openChangesReview: (coordinates, index) => { + ctx.sidebarRight.openResource(changesReviewAddress(coordinates), { params: { index } }) + }, }), }, Deliverables), ) ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( { name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow, )) - ctx.effect(() => ctx.sidebarRightTabs.register(changesDiffDefinition()), 'ui-deliverables: changes-diff type') + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.sidebarRightTabs.register(changesReviewDefinition(t)), 'ui-deliverables: changes-review type') ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( { - name: 'sidebar.right.pane.tab', key: CHANGES_DIFF_ID, locale: NS, - inject: (): DiffPreviewInjected => ({ - hooks: { changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host }, + name: 'sidebar.right.pane.tab', key: CHANGES_REVIEW_ID, locale: NS, store: createReviewStore(), + inject: (): ReviewInjected => ({ + hooks: { changesSummary: summaries.state, changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host }, + loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index), reloadPresentedHost: () => opener.loadHost(), openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), }), }, - DiffPreview, - )), 'ui-deliverables: changes-diff body') + ReviewTab, + )), 'ui-deliverables: changes-review body') // The prose side of the same vocabulary: the chat view reaches this face // via ctx.get, so its absence — this plugin composed out — is the off state. - const t = ctx.locale.bind(NS) const mentions: ChatFileMentions = { forClosing(owner) { const paths = selectProducedFiles(owner) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 85cdaefab1..4cdbcc448a 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -51,6 +51,17 @@ export const zh = { 'changes.folderError': '无法打开文件夹,点击重试', 'changes.oversized': '过大', 'changes.viewDiff': '查看 {name} 的改动', + 'review.title': '第 {turn} 轮改动', + 'review.selectFile': '选择要查看的文件', + 'review.split': '切换为左右对比', + 'review.unified': '切换为单栏对比', + 'review.splitAria': '左右对比', + 'review.wrap': '开启自动换行', + 'review.nowrap': '关闭自动换行', + 'review.wrapAria': '自动换行', + 'review.openFile': '在侧边栏打开整个文件', + 'review.openFileAria': '在侧边栏打开 {name}', + 'diff.openNativeAria': '用默认应用打开 {name}', 'diff.loading': '正在读取改动…', 'diff.missing': '这轮改动的内容已不可用', 'diff.error': '无法读取改动', @@ -113,6 +124,17 @@ export const en: Record = { 'changes.folderError': 'Could not open folder. Click to retry.', 'changes.oversized': 'too large', 'changes.viewDiff': 'View changes to {name}', + 'review.title': 'Review · turn {turn}', + 'review.selectFile': 'Choose the file to review', + 'review.split': 'Switch to split view', + 'review.unified': 'Switch to unified view', + 'review.splitAria': 'Split view', + 'review.wrap': 'Enable line wrap', + 'review.nowrap': 'Disable line wrap', + 'review.wrapAria': 'Line wrap', + 'review.openFile': 'Open the whole file in the sidebar', + 'review.openFileAria': 'Open {name} in sidebar', + 'diff.openNativeAria': 'Open {name} in default app', 'diff.loading': 'Reading changes…', 'diff.missing': 'The contents of this turn’s changes are no longer available', 'diff.error': 'Could not read the changes', diff --git a/packages/client/ui-deliverables/src/client/review-definition.ts b/packages/client/ui-deliverables/src/client/review-definition.ts new file mode 100644 index 0000000000..9b0ad483a1 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/review-definition.ts @@ -0,0 +1,50 @@ +/** + * What the `changes-review` tab type IS: the right-Sidebar review of one + * turn's changed files, showing one file's turn-start and turn-end + * comparison at a time. It claims the `dsh-resource://changes-review/session/…` + * addresses the changed-files card mints; the Session and the event sequence + * in the address identify the content, and the turn they carry names the tab. + * A row opens the tab with the file's index as its navigation parameter. + */ +import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import { parseChangesReviewAddress } from '../changes.ts' +import type { NS } from './locales.ts' + +/** The tab kind this package owns. */ +export const CHANGES_REVIEW_KIND = 'changes-review' + +/** This implementation's identity in the tab system, and the key its body registers under. */ +export const CHANGES_REVIEW_ID = '@deepseek-ai/dsh-client-ui-deliverables' + +/** The file a review opens on. */ +export interface ChangesReviewParams { + /** Original index in the summary's files array. */ + index?: number +} + +declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { + interface SidebarRightResourceParamsMap { + /** The file a review tab opens on. */ + 'changes-review': ChangesReviewParams + } +} + +/** + * The review type's registry definition. + * @param t - namespace-bound translate, read fresh on every title call. + * @returns the definition to register. + */ +export function changesReviewDefinition(t: TranslateNS): SidebarRightTabDefinition { + return { + id: CHANGES_REVIEW_ID, + kind: CHANGES_REVIEW_KIND, + patterns: ['dsh-resource://changes-review/**'], + priority: 'builtin', + canOpen: address => parseChangesReviewAddress(address) !== undefined, + title: (address) => { + const turn = parseChangesReviewAddress(address)?.turn + return turn === undefined ? address : t('review.title', { turn: String(turn) }) + }, + } +} diff --git a/packages/client/ui-deliverables/src/client/review-store.ts b/packages/client/ui-deliverables/src/client/review-store.ts new file mode 100644 index 0000000000..b4edbc4f4f --- /dev/null +++ b/packages/client/ui-deliverables/src/client/review-store.ts @@ -0,0 +1,92 @@ +/** + * The review tab's view state: which listed file is shown, whether hunks are + * drawn side by side, and whether long lines wrap. One bucket per tab, so two + * reviews in one session keep their own choices; the bucket ends with the + * tab record's signal. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' + +/** One review tab's choices. */ +export interface ReviewTabState { + /** Original index of the shown file in the summary's files array. */ + index: number + /** Whether deletions and additions are drawn in two columns. */ + split: boolean + /** Whether long lines wrap instead of scrolling. */ + wrap: boolean + /** The navigation revision whose file index was last applied. */ + navigated: number +} + +/** Every review tab's choices, keyed by tab id. */ +export interface ReviewState { + byTab: Record +} + +function bucket(state: ReviewState, tabId: TabId): ReviewTabState { + const tab = state.byTab[tabId] + if (tab === undefined) throw new Error(`ui-deliverables: no review state for tab "${tabId}"`) + return tab +} + +/** The review store's write set; every action names the tab it writes. */ +type ReviewActions = { + navigated: (draft: ReviewState, tabId: TabId, revision: number, index: number) => void + selected: (draft: ReviewState, tabId: TabId, index: number) => void + toggledSplit: (draft: ReviewState, tabId: TabId) => void + toggledWrap: (draft: ReviewState, tabId: TabId) => void + forget: (draft: ReviewState, tabId: TabId) => void +} + +/** + * Declare the review tab's store; the registration declares it as an + * exclusive store, so the framework mints one instance per session. + * @returns the store handle to declare on the registration. + */ +export function createReviewStore(): EngineStoreHandle { + return defineStore({ + init: (): ReviewState => ({ byTab: {} }), + actions: { + /** + * Apply a navigation: seed the tab on its first one, then show the navigated file. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param revision - the navigation revision being applied. + * @param index - the file index the navigation named, or the current one. + */ + navigated: (d, tabId: TabId, revision: number, index: number) => { + const tab = d.byTab[tabId] + if (tab === undefined) d.byTab[tabId] = { index, split: false, wrap: false, navigated: revision } + else { tab.index = index; tab.navigated = revision } + }, + /** + * Show another listed file. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param index - original index in the summary's files array. + */ + selected: (d, tabId: TabId, index: number) => { bucket(d, tabId).index = index }, + /** + * Switch between the unified and the side-by-side view. + * @param d - draft state. + * @param tabId - the tab being drawn. + */ + toggledSplit: (d, tabId: TabId) => { const tab = bucket(d, tabId); tab.split = !tab.split }, + /** + * Switch line wrapping. + * @param d - draft state. + * @param tabId - the tab being drawn. + */ + toggledWrap: (d, tabId: TabId) => { const tab = bucket(d, tabId); tab.wrap = !tab.wrap }, + /** + * Drop a tab's bucket once its record is gone. + * @param d - draft state. + * @param tabId - the tab that ended. + */ + forget: (d, tabId: TabId) => { + d.byTab = Object.fromEntries(Object.entries(d.byTab).filter(([id]) => id !== tabId)) + }, + }, + }) +} diff --git a/packages/client/ui-deliverables/tests/changes-diff.client.spec.ts b/packages/client/ui-deliverables/tests/changes-diff.client.spec.ts new file mode 100644 index 0000000000..e295231a7f --- /dev/null +++ b/packages/client/ui-deliverables/tests/changes-diff.client.spec.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +/** The comparison store: served and missing comparisons stay, failures retry, resets and disposal forget. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { changesDiffUrl, type ChangesDiff } from '../src/changes.ts' +import { ChangesDiffStore } from '../src/client/changes-diff.ts' + +afterEach(() => { vi.unstubAllGlobals() }) + +const SESSION = SessionId('viewed') +const URL_ = changesDiffUrl(SESSION, 5, 1) +const text: ChangesDiff = { + kind: 'text', path: 'a.ts', display: 'a.ts', before: true, after: true, coarse: false, + hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }], +} + +describe('ChangesDiffStore', () => { + it('keeps served and missing comparisons, retries failures, and forgets on reset and disposal', async () => { + const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise>() + vi.stubGlobal('fetch', fetcher) + const store = new ChangesDiffStore() + fetcher.mockResolvedValueOnce(Response.json(text)) + await store.load(SESSION, 5, 1) + expect(store.state.getSnapshot()[URL_]).toEqual(text) + await store.load(SESSION, 5, 1) + expect(fetcher).toHaveBeenCalledTimes(1) + fetcher.mockResolvedValueOnce(new Response('gone', { status: 404 })) + await store.load(SESSION, 5, 2) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 2)]).toBe('missing') + await store.load(SESSION, 5, 2) + expect(fetcher).toHaveBeenCalledTimes(2) + fetcher.mockResolvedValueOnce(new Response('boom', { status: 500 })) + await store.load(SESSION, 5, 3) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error') + fetcher.mockResolvedValueOnce(Response.json({ kind: 'text' })) + await store.load(SESSION, 5, 3) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error') + fetcher.mockRejectedValueOnce(new Error('offline')) + await store.load(SESSION, 5, 3) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error') + fetcher.mockResolvedValueOnce(Response.json({ kind: 'oversized', path: 'p', display: 'p' })) + await store.load(SESSION, 5, 3) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toEqual({ kind: 'oversized', path: 'p', display: 'p' }) + store.reset() + expect(store.state.getSnapshot()).toEqual({}) + let settle!: (response: Response) => void + fetcher.mockReturnValueOnce(new Promise((resolve) => { settle = resolve })) + const stale = store.load(SESSION, 5, 1) + expect(store.state.getSnapshot()[URL_]).toBe('loading') + store.reset() + settle(Response.json(text)) + await stale + expect(store.state.getSnapshot()[URL_]).toBeUndefined() + fetcher.mockReturnValueOnce(new Promise((resolve) => { settle = resolve })) + const late = store.load(SESSION, 5, 1) + const disposal = store.dispose() + settle(Response.json(text)) + await Promise.all([late, disposal]) + expect(store.state.getSnapshot()[URL_]).toBe('loading') + await store.load(SESSION, 6, 0) + expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 6, 0)]).toBeUndefined() + }) +}) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index a59ea4ad70..574b722ce7 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -23,7 +23,7 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' -import type { DiffPreviewInjected } from '../src/client/DiffPreview.tsx' +import type { ReviewInjected } from '../src/client/ReviewTab.tsx' import { ChangesSummaryStore } from '../src/client/changes-summary.ts' import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts' import { PresentedOpenController } from '../src/client/present-open.ts' @@ -49,7 +49,7 @@ function openProps(controller = new PresentedOpenController(), summaries = new C select(controller.host.getSnapshot()), openPresented: vi.fn((...args: Parameters) => controller.open(...args)), openChanged: vi.fn((...args: Parameters) => controller.openChanged(...args)), - openChangesDiff: vi.fn(), + openChangesReview: vi.fn(), usePresentedOpen: (select: (state: ReturnType) => T): T => select(controller.state.getSnapshot()), } @@ -567,7 +567,7 @@ describe('ChangedFiles card', () => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading') }) - it('summarizes the turn, folds after three rows, opens each row’s comparison, and opens the folder natively', () => { + it('summarizes the turn, folds after three rows, opens the review on each row, and opens the folder natively', () => { const { props, openFile, view } = renderCard() const card = view.container.querySelector('[data-changed-files]') if (!(card instanceof HTMLElement)) throw new Error('changed-files card missing') @@ -579,7 +579,7 @@ describe('ChangedFiles card', () => { expect(within(card).getByText('+42')).toBeTruthy() expect(within(card).queryByText('src/index.ts')).toBeNull() fireEvent.click(within(card).getByRole('button', { name: 'View changes to config/feature-flags.json' })) - expect(props.openChangesDiff).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, index: 1, display: 'config/feature-flags.json' }) + expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 1) expect(props.openChanged).not.toHaveBeenCalled() fireEvent.click(within(card).getByRole('button', { name: 'Open the folder containing the changed files' })) expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, null) @@ -592,7 +592,7 @@ describe('ChangedFiles card', () => { expect(within(card).getByText('binary')).toBeTruthy() expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBe('/home/u/.zshrc') fireEvent.click(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' })) - expect(props.openChangesDiff).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, index: 4, display: '~/.zshrc' }) + expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 4) const collapse = within(card).getByRole('button', { name: 'Collapse changed files' }) expect(collapse.getAttribute('aria-expanded')).toBe('true') expect(card.lastElementChild).toBe(collapse) @@ -600,7 +600,7 @@ describe('ChangedFiles card', () => { expect(within(card).getAllByRole('listitem')).toHaveLength(3) }) - it('still opens each row’s comparison and offers no folder action without a desktop', () => { + it('still opens the review on each row and offers no folder action without a desktop', () => { const controller = new PresentedOpenController() const { openFile, props, view } = renderCard(controller, zh) controller.host.set('error') @@ -611,7 +611,7 @@ describe('ChangedFiles card', () => { expect(view.getByText('已编辑 11 个文件')).toBeTruthy() expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull() fireEvent.click(view.getByRole('button', { name: '查看 config/design-token 的改动' })) - expect(props.openChangesDiff).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, index: 0, display: 'config/design-token' }) + expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 0) expect(openFile).not.toHaveBeenCalled() expect(props.openChanged).not.toHaveBeenCalled() expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件') @@ -724,7 +724,7 @@ describe('plugin registration', () => { expect(entry).toBeDefined() expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(1) expect(entry?.inject).toBeDefined() - expect(registered).toMatchObject({ kind: 'changes-diff', patterns: ['dsh-resource://changes-diff/**'] }) + expect(registered).toMatchObject({ kind: 'changes-review', patterns: ['dsh-resource://changes-review/**'] }) const [tabEntry] = ctx.slots.entries('sidebar.right.pane.tab') expect(tabEntry?.options.key).toBe('@deepseek-ai/dsh-client-ui-deliverables') @@ -770,9 +770,13 @@ describe('plugin registration', () => { expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') await face.openChanged(SessionId('child-session'), 5, null) expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5']).toBe('opened') - face.openChangesDiff({ sessionId: SessionId('child-session'), seq: 5, index: 1, display: 'src/a.ts' }) - expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-diff/session/child-session/5/1/src%2Fa.ts') - const tabFace = tabEntry!.inject!(SessionId('child-session') as never) as unknown as DiffPreviewInjected + face.openChangesReview({ sessionId: SessionId('child-session'), seq: 5, turn: 3 }, 1) + expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-review/session/child-session/5/3', { params: { index: 1 } }) + expect((registered as { title(address: string): string }).title('dsh-resource://changes-review/session/child-session/5/3')).toBe('Review · turn 3') + const tabFace = tabEntry!.inject!(SessionId('child-session') as never) as unknown as ReviewInjected + fetcher.mockResolvedValueOnce(Response.json({ turn: 3, files: [], total: 0, added: 0, deleted: 0 })) + await tabFace.loadChangesSummary(SessionId('child-session'), 6) + expect(tabFace.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }) fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })) await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1) expect(tabFace.hooks.changesDiff.getSnapshot()['/api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) diff --git a/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx b/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx deleted file mode 100644 index 3052775db6..0000000000 --- a/packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx +++ /dev/null @@ -1,237 +0,0 @@ -// @vitest-environment jsdom -/** The comparison tab type: its addresses, its Host reads, and the states its body draws. */ -import { cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { SessionId } from '@deepseek-ai/dsh-session/types' -import { changesDiffAddress, changesDiffUrl, parseChangesDiffAddress, type ChangesDiff } from '../src/changes.ts' -import { ChangesDiffStore } from '../src/client/changes-diff.ts' -import { DiffPreview, hunkRows, MAX_RENDERED_LINES, renderedHunks, type DiffPreviewInjected, type DiffPreviewProps } from '../src/client/DiffPreview.tsx' -import { changesDiffDefinition } from '../src/client/diff-definition.ts' -import { PresentedOpenController } from '../src/client/present-open.ts' -import { en, zh } from '../src/client/locales.ts' - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() -}) - -const SESSION = SessionId('viewed') -const COORDINATES = { sessionId: SESSION, seq: 5, index: 1, display: 'src/app/main.ts' } -const URL_ = changesDiffUrl(SESSION, 5, 1) - -const text: ChangesDiff = { - kind: 'text', path: 'src/app/main.ts', display: 'src/app/main.ts', before: true, after: true, coarse: false, - hunks: [ - { oldStart: 1, oldLines: 3, newStart: 1, newLines: 4, lines: [' a', '-b', '+B', '+c', ' d'] }, - { oldStart: 10, oldLines: 1, newStart: 11, newLines: 1, lines: ['-x', '+y'] }, - ], -} - -describe('comparison addresses', () => { - it('round-trips coordinates and titles the tab by the file name', () => { - const address = changesDiffAddress({ ...COORDINATES, display: 'dir/we ird#name.ts' }) - expect(address).toBe('dsh-resource://changes-diff/session/viewed/5/1/dir%2Fwe%20ird%23name.ts') - expect(parseChangesDiffAddress(address)).toEqual({ ...COORDINATES, display: 'dir/we ird#name.ts' }) - const definition = changesDiffDefinition() - expect(definition).toMatchObject({ kind: 'changes-diff', priority: 'builtin', patterns: ['dsh-resource://changes-diff/**'] }) - expect(definition.canOpen?.(address)).toBe(true) - expect(definition.title(address)).toBe('we ird#name.ts') - for (const bad of [ - 'dsh-resource://file/session/viewed/a.ts', 'dsh-resource://changes-diff/session/viewed/5/1', - 'dsh-resource://changes-diff/session//5/1/a', 'dsh-resource://changes-diff/session/viewed/x/1/a', - 'dsh-resource://changes-diff/session/viewed/5/1/', 'dsh-resource://changes-diff/session/viewed/5/1/%E0%A4%A', - ]) { - expect(parseChangesDiffAddress(bad)).toBeUndefined() - expect(definition.canOpen?.(bad)).toBe(false) - expect(definition.title(bad)).toBe(bad) - } - }) -}) - -describe('ChangesDiffStore', () => { - it('keeps served and missing comparisons, retries failures, and forgets on reset and disposal', async () => { - const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise>() - vi.stubGlobal('fetch', fetcher) - const store = new ChangesDiffStore() - fetcher.mockResolvedValueOnce(Response.json(text)) - await store.load(SESSION, 5, 1) - expect(store.state.getSnapshot()[URL_]).toEqual(text) - await store.load(SESSION, 5, 1) - expect(fetcher).toHaveBeenCalledTimes(1) - fetcher.mockResolvedValueOnce(new Response('gone', { status: 404 })) - await store.load(SESSION, 5, 2) - expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 2)]).toBe('missing') - await store.load(SESSION, 5, 2) - expect(fetcher).toHaveBeenCalledTimes(2) - fetcher.mockResolvedValueOnce(new Response('boom', { status: 500 })) - await store.load(SESSION, 5, 3) - expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error') - fetcher.mockResolvedValueOnce(Response.json({ kind: 'text' })) - await store.load(SESSION, 5, 3) - expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error') - fetcher.mockRejectedValueOnce(new Error('offline')) - await store.load(SESSION, 5, 3) - expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error') - fetcher.mockResolvedValueOnce(Response.json({ kind: 'oversized', path: 'p', display: 'p' })) - await store.load(SESSION, 5, 3) - expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toEqual({ kind: 'oversized', path: 'p', display: 'p' }) - store.reset() - expect(store.state.getSnapshot()).toEqual({}) - let settle!: (response: Response) => void - fetcher.mockReturnValueOnce(new Promise((resolve) => { settle = resolve })) - const stale = store.load(SESSION, 5, 1) - expect(store.state.getSnapshot()[URL_]).toBe('loading') - store.reset() - settle(Response.json(text)) - await stale - expect(store.state.getSnapshot()[URL_]).toBeUndefined() - fetcher.mockReturnValueOnce(new Promise((resolve) => { settle = resolve })) - const late = store.load(SESSION, 5, 1) - const disposal = store.dispose() - settle(Response.json(text)) - await Promise.all([late, disposal]) - expect(store.state.getSnapshot()[URL_]).toBe('loading') - await store.load(SESSION, 6, 0) - expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 6, 0)]).toBeUndefined() - }) -}) - -describe('DiffPreview', () => { - function mount( - diffs = new ChangesDiffStore(), controller = new PresentedOpenController(), locale = en, address = changesDiffAddress(COORDINATES), - ) { - const injected = { - loadChangesDiff: vi.fn(() => Promise.resolve()), - reloadPresentedHost: vi.fn(() => Promise.resolve()), - openChanged: vi.fn(() => Promise.resolve()), - } - const runtime = { - useTabInfo: () => ({ tab: { contentId: address } }), - useChangesDiff: (select: (state: ReturnType) => T): T => select(diffs.state.getSnapshot()), - usePresentedOpen: (select: (state: ReturnType) => T): T => - select(controller.state.getSnapshot()), - usePresentedHost: (select: (state: ReturnType) => T): T => - select(controller.host.getSnapshot()), - t: makeTranslate(locale), - ...injected, - } as unknown as DiffPreviewProps - const view = render() - return { view, injected, rerender: () => { view.rerender() } } - } - - it('numbers hunk lines on the side they belong to', () => { - expect(hunkRows(text.hunks[0]!)).toEqual([ - { kind: 'context', old: 1, new: 1, text: 'a' }, - { kind: 'del', old: 2, new: undefined, text: 'b' }, - { kind: 'add', old: undefined, new: 2, text: 'B' }, - { kind: 'add', old: undefined, new: 3, text: 'c' }, - { kind: 'context', old: 3, new: 4, text: 'd' }, - ]) - }) - - it('asks for the comparison once, then draws its hunks with line numbers and the path header', () => { - const diffs = new ChangesDiffStore() - const { view, injected, rerender } = mount(diffs) - expect(injected.loadChangesDiff).toHaveBeenCalledWith('viewed', 5, 1) - expect(injected.reloadPresentedHost).toHaveBeenCalledTimes(1) - expect(view.getByRole('status').textContent).toBe(en['diff.loading']) - expect(view.getByTitle('src/app/main.ts').textContent).toBe('src/app/main.ts') - diffs.state.set({ [URL_]: text }) - rerender() - expect(injected.loadChangesDiff).toHaveBeenCalledTimes(1) - const root = view.container.querySelector('[data-changes-diff]') - expect(root?.getAttribute('data-diff-state')).toBe('text') - expect(view.getByText('@@ -1,3 +1,4 @@')).toBeTruthy() - const lines = [...view.container.querySelectorAll('[data-diff-line]')] - expect(lines.map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'add', 'context', 'del', 'add']) - expect(lines[1]?.textContent).toBe('2-b') - expect(lines[2]?.textContent).toBe('2+B') - expect(lines[4]?.textContent).toBe('34 d') - expect(view.queryByText(en['diff.created'])).toBeNull() - // Without a desktop the header carries no native-open control. - expect(view.queryByRole('button')).toBeNull() - }) - - it('states created, deleted, unchanged, and coarse comparisons, and the binary, oversized, missing, and failed reads', () => { - const diffs = new ChangesDiffStore() - const { view, injected, rerender } = mount(diffs, new PresentedOpenController(), zh) - diffs.state.set({ [URL_]: { ...text, before: false, coarse: true } }) - rerender() - expect(view.getByText(zh['diff.created'])).toBeTruthy() - expect(view.container.querySelector('[data-diff-coarse]')?.textContent).toBe(zh['diff.coarse']) - diffs.state.set({ [URL_]: { ...text, after: false } }) - rerender() - expect(view.getByText(zh['diff.deleted'])).toBeTruthy() - diffs.state.set({ [URL_]: { ...text, hunks: [] } }) - rerender() - expect(view.getByText(zh['diff.unchanged'])).toBeTruthy() - diffs.state.set({ [URL_]: { kind: 'binary', path: 'p', display: 'other/name.bin' } }) - rerender() - expect(view.getByText(zh['diff.binary'])).toBeTruthy() - expect(view.getByTitle('other/name.bin')).toBeTruthy() - diffs.state.set({ [URL_]: { kind: 'oversized', path: 'p', display: 'p' } }) - rerender() - expect(view.getByText(zh['diff.oversized'])).toBeTruthy() - diffs.state.set({ [URL_]: 'missing' }) - rerender() - expect(view.getByText(zh['diff.missing'])).toBeTruthy() - diffs.state.set({ [URL_]: 'error' }) - rerender() - expect(view.getByText(zh['diff.error'])).toBeTruthy() - fireEvent.click(view.getByRole('button', { name: zh['presented.retry'] })) - expect(injected.loadChangesDiff).toHaveBeenCalledTimes(2) - }) - - it('offers the native open only with a desktop and shows its pending and failed states', () => { - const diffs = new ChangesDiffStore() - diffs.state.set({ [URL_]: text }) - const controller = new PresentedOpenController() - controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) - const { view, injected, rerender } = mount(diffs, controller) - expect(injected.reloadPresentedHost).not.toHaveBeenCalled() - fireEvent.click(view.getByRole('button', { name: en['diff.openNative'] })) - expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1) - controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) - rerender() - expect((view.getByRole('button', { name: en['presented.opening'] }) as HTMLButtonElement).disabled).toBe(true) - controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) - rerender() - expect(view.getByRole('button', { name: en['diff.openNativeError'] })).toBeTruthy() - controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) - rerender() - expect(view.queryByRole('button')).toBeNull() - controller.state.set({}) - controller.host.set({ name: 'server', available: false, fileManager: null }) - rerender() - expect(view.queryByRole('button')).toBeNull() - controller.host.set('error') - rerender() - expect(view.queryByRole('button')).toBeNull() - }) - - it('stops drawing after the rendered-line cap and says so', () => { - const long = (count: number, prefix = '+'): string[] => Array.from({ length: count }, (_, at) => `${prefix}line ${at}`) - const whole = { oldStart: 1, oldLines: 0, newStart: 1, newLines: MAX_RENDERED_LINES + 2, lines: long(MAX_RENDERED_LINES + 2) } - const cut = renderedHunks([whole]) - expect(cut.truncated).toBe(true) - expect(cut.hunks[0]!.lines).toHaveLength(MAX_RENDERED_LINES) - const small = { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] } - expect(renderedHunks([small, small])).toEqual({ hunks: [small, small], truncated: false }) - // A later hunk past the budget is dropped whole, an earlier one shortened. - const two = renderedHunks([{ ...whole, lines: long(MAX_RENDERED_LINES) }, small]) - expect(two.hunks).toHaveLength(1) - expect(two.truncated).toBe(true) - const diffs = new ChangesDiffStore() - diffs.state.set({ [URL_]: { ...text, hunks: [{ ...whole, lines: long(MAX_RENDERED_LINES + 1) }] } }) - const { view } = mount(diffs) - expect(view.container.querySelectorAll('[data-diff-line]')).toHaveLength(MAX_RENDERED_LINES) - expect(view.container.querySelector('[data-diff-truncated]')?.textContent).toBe(`Showing the first ${MAX_RENDERED_LINES} lines`) - }) - - it('refuses an address it did not mint', () => { - const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) - expect(() => mount(undefined, undefined, en, 'dsh-resource://file/session/viewed/a.ts')).toThrow('not a comparison address') - error.mockRestore() - }) -}) diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx new file mode 100644 index 0000000000..084b0205e1 --- /dev/null +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -0,0 +1,324 @@ +// @vitest-environment jsdom +/** The review tab type: its addresses, its store, the row pairing of the split view, and the states its body draws. */ +import { useSyncExternalStore } from 'react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SidebarRightTabActions } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' +import type { WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types' +import { + changesDiffUrl, changesReviewAddress, changesSummaryUrl, parseChangesReviewAddress, type ChangesDiff, type ChangesSummary, +} from '../src/changes.ts' +import { ChangesDiffStore } from '../src/client/changes-diff.ts' +import { ChangesSummaryStore } from '../src/client/changes-summary.ts' +import { PresentedOpenController } from '../src/client/present-open.ts' +import { + hunkRows, MAX_RENDERED_LINES, renderedHunks, ReviewTab, splitRows, type ReviewInjected, type ReviewTabProps, +} from '../src/client/ReviewTab.tsx' +import { changesReviewDefinition } from '../src/client/review-definition.ts' +import { createReviewStore } from '../src/client/review-store.ts' +import { en, zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +const SESSION = SessionId('viewed') +const TAB = 'tab-1' as TabId +const COORDINATES = { sessionId: SESSION, seq: 5, turn: 2 } +const ADDRESS = changesReviewAddress(COORDINATES) +const SUMMARY_URL = changesSummaryUrl(SESSION, 5) + +const summary: ChangesSummary = { + turn: 2, total: 4, added: 4, deleted: 2, + files: [ + { path: 'src/app/main.ts', display: 'src/app/main.ts', added: 3, deleted: 1 }, + { path: '/tmp/out/big.bin', display: '~/out/big.bin', added: 0, deleted: 0, oversized: true }, + { path: 'notes.txt', display: 'notes.txt', added: 1, deleted: 1 }, + { path: 'logo.png', display: 'logo.png', added: 0, deleted: 0, binary: true }, + ], +} + +const text: ChangesDiff = { + kind: 'text', path: 'src/app/main.ts', display: 'src/app/main.ts', before: true, after: true, coarse: false, + hunks: [ + { oldStart: 1, oldLines: 3, newStart: 1, newLines: 4, lines: [' a', '-b', '+B', '+c', ' d'] }, + { oldStart: 10, oldLines: 1, newStart: 11, newLines: 1, lines: ['-x', '+y'] }, + { oldStart: 20, oldLines: 1, newStart: 22, newLines: 0, lines: ['-z'] }, + ], +} + +/** Test-local selector hook over a framework-neutral store instance. */ +function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { + return function useSelector(sel: (s: T) => S): S { + return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) + } +} + +describe('review addresses', () => { + it('round-trips coordinates and titles the tab by the turn', () => { + expect(ADDRESS).toBe('dsh-resource://changes-review/session/viewed/5/2') + expect(parseChangesReviewAddress(ADDRESS)).toEqual(COORDINATES) + const definition = changesReviewDefinition(makeTranslate(zh)) + expect(definition).toMatchObject({ kind: 'changes-review', priority: 'builtin', patterns: ['dsh-resource://changes-review/**'] }) + expect(definition.canOpen?.(ADDRESS)).toBe(true) + expect(definition.title(ADDRESS)).toBe('第 2 轮改动') + for (const bad of [ + 'dsh-resource://file/session/viewed/a.ts', 'dsh-resource://changes-review/session/viewed/5', + 'dsh-resource://changes-review/session//5/2', 'dsh-resource://changes-review/session/viewed/x/2', + 'dsh-resource://changes-review/session/viewed/5/0', 'dsh-resource://changes-review/session/%E0%A4%A/5/2', + ]) { + expect(parseChangesReviewAddress(bad)).toBeUndefined() + expect(definition.canOpen?.(bad)).toBe(false) + expect(definition.title(bad)).toBe(bad) + } + }) +}) + +describe('review store', () => { + it('seeds a tab on its first navigation, applies later ones, toggles views, and forgets', () => { + const store = createReviewStore().create() + store.actions.navigated(TAB, 1, 2) + expect(store.getSnapshot().byTab[TAB]).toEqual({ index: 2, split: false, wrap: false, navigated: 1 }) + store.actions.toggledSplit(TAB) + store.actions.toggledWrap(TAB) + store.actions.selected(TAB, 0) + store.actions.navigated(TAB, 2, 1) + expect(store.getSnapshot().byTab[TAB]).toEqual({ index: 1, split: true, wrap: true, navigated: 2 }) + store.actions.forget(TAB) + expect(store.getSnapshot().byTab[TAB]).toBeUndefined() + expect(() => { store.actions.selected(TAB, 0) }).toThrow('no review state') + }) +}) + +describe('hunk rows', () => { + it('numbers unified rows on the side they belong to and pairs deletions with additions for the split view', () => { + expect(hunkRows(text.hunks[0]!)).toEqual([ + { kind: 'context', old: 1, new: 1, text: 'a' }, + { kind: 'del', old: 2, new: undefined, text: 'b' }, + { kind: 'add', old: undefined, new: 2, text: 'B' }, + { kind: 'add', old: undefined, new: 3, text: 'c' }, + { kind: 'context', old: 3, new: 4, text: 'd' }, + ]) + expect(splitRows(text.hunks[0]!)).toEqual([ + { left: { no: 1, text: 'a', kind: 'context' }, right: { no: 1, text: 'a', kind: 'context' } }, + { left: { no: 2, text: 'b', kind: 'del' }, right: { no: 2, text: 'B', kind: 'add' } }, + { right: { no: 3, text: 'c', kind: 'add' } }, + { left: { no: 3, text: 'd', kind: 'context' }, right: { no: 4, text: 'd', kind: 'context' } }, + ]) + // A trailing run of deletions with no additions ends the hunk on the left alone. + expect(splitRows({ oldStart: 1, oldLines: 2, newStart: 1, newLines: 0, lines: ['-p', '-q'] })).toEqual([ + { left: { no: 1, text: 'p', kind: 'del' } }, { left: { no: 2, text: 'q', kind: 'del' } }, + ]) + }) + + it('cuts the drawn hunks at the rendered-line cap', () => { + const long = (count: number): string[] => Array.from({ length: count }, (_, at) => `+line ${at}`) + const whole: WorkspaceDiffHunk = { + oldStart: 1, oldLines: 0, newStart: 1, newLines: MAX_RENDERED_LINES + 2, lines: long(MAX_RENDERED_LINES + 2), + } + const cut = renderedHunks([whole]) + expect(cut.truncated).toBe(true) + expect(cut.hunks[0]!.lines).toHaveLength(MAX_RENDERED_LINES) + const small: WorkspaceDiffHunk = { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] } + expect(renderedHunks([small, small])).toEqual({ hunks: [small, small], truncated: false }) + const two = renderedHunks([{ ...whole, lines: long(MAX_RENDERED_LINES) }, small]) + expect(two.hunks).toHaveLength(1) + expect(two.truncated).toBe(true) + }) +}) + +describe('ReviewTab', () => { + function mount(options: { + summaries?: ChangesSummaryStore + diffs?: ChangesDiffStore + controller?: PresentedOpenController + locale?: typeof en + params?: unknown + revision?: number + address?: string + cwd?: string | undefined + } = {}) { + const summaries = options.summaries ?? new ChangesSummaryStore() + const diffs = options.diffs ?? new ChangesDiffStore() + const controller = options.controller ?? new PresentedOpenController() + const store = createReviewStore().create() + const aborter = new AbortController() + const tabActions = { + openResource: vi.fn(), + openTab: vi.fn(), + close: vi.fn(), + } + const injected = { + loadChangesSummary: vi.fn(() => Promise.resolve()), + loadChangesDiff: vi.fn(() => Promise.resolve()), + reloadPresentedHost: vi.fn(() => Promise.resolve()), + openChanged: vi.fn(() => Promise.resolve()), + } + const sessions = { byId: { [SESSION]: { cwd: options.cwd ?? '/work/app' } } } as unknown as SessionListState + const navigation = { address: options.address ?? ADDRESS, params: options.params, revision: options.revision ?? 1 } + const runtime = { + useTabInfo: () => ({ + sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1' }, + tab: { id: TAB, kind: 'changes-review', contentId: options.address ?? ADDRESS, title: 'Review', visible: true, navigation, signal: aborter.signal, actions: tabActions }, + }), + sessionId: SESSION, + useSessions: (select: (state: SessionListState) => T): T => select(sessions), + useStore: hookOf(store), + actions: store.actions, + useChangesSummary: hookOf(summaries.state), + useChangesDiff: hookOf(diffs.state), + usePresentedOpen: hookOf(controller.state), + usePresentedHost: hookOf(controller.host), + t: makeTranslate(options.locale ?? en), + ...injected, + } as unknown as ReviewTabProps + const view = render() + return { + view, injected, tabActions, store, aborter, summaries, diffs, controller, + rerender: () => { view.rerender() }, + } + } + + it('reads the summary, opens on the navigated file, and switches files through the selector', () => { + const summaries = new ChangesSummaryStore() + const diffs = new ChangesDiffStore() + const { view, injected, store } = mount({ summaries, diffs, params: { index: 2 } }) + expect(injected.loadChangesSummary).toHaveBeenCalledWith('viewed', 5) + expect(injected.reloadPresentedHost).toHaveBeenCalledTimes(1) + expect(view.container.querySelector('[data-changes-review]')?.getAttribute('data-review-state')).toBe('loading') + expect(view.getByText('Review · turn 2')).toBeTruthy() + act(() => { summaries.state.set({ [SUMMARY_URL]: summary }) }) + expect(store.getSnapshot().byTab[TAB]).toMatchObject({ index: 2, navigated: 1 }) + const selector = view.getByRole('button', { name: en['review.selectFile'] }) + expect(selector.getAttribute('data-review-file')).toBe('notes.txt') + expect(injected.loadChangesDiff).toHaveBeenLastCalledWith('viewed', 5, 2) + expect(view.getAllByText('+1')).toHaveLength(1) + fireEvent.click(selector) + expect(selector.getAttribute('aria-expanded')).toBe('true') + // Escape closes the selector without changing the file. + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }) + expect(view.getByRole('button', { name: en['review.selectFile'] }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(view.getByRole('button', { name: en['review.selectFile'] })) + const items = view.getAllByRole('menuitem') + expect(items.map(item => item.textContent)).toEqual([ + 'src/app/main.ts+3-1', '~/out/big.bin' + en['changes.oversized'], 'notes.txt+1-1', 'logo.png' + en['changes.binary'], + ]) + fireEvent.click(items[0]!) + expect(store.getSnapshot().byTab[TAB]?.index).toBe(0) + expect(view.getByRole('button', { name: en['review.selectFile'] }).getAttribute('data-review-file')).toBe('src/app/main.ts') + expect(injected.loadChangesDiff).toHaveBeenLastCalledWith('viewed', 5, 0) + act(() => { diffs.state.set({ [changesDiffUrl(SESSION, 5, 0)]: text }) }) + const lines = [...view.container.querySelectorAll('[data-diff-line]')] + expect(lines.map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'add', 'context', 'del', 'add', 'del']) + expect(lines[1]?.textContent).toBe('2-b') + expect(lines[4]?.textContent).toBe('34 d') + expect(view.container.querySelector('[data-review-view]')?.getAttribute('data-review-view')).toBe('unified') + }) + + it('draws the split view and wraps lines on request, and keeps both choices in the tab store', () => { + const summaries = new ChangesSummaryStore() + summaries.state.set({ [SUMMARY_URL]: summary }) + const diffs = new ChangesDiffStore() + diffs.state.set({ [changesDiffUrl(SESSION, 5, 0)]: text }) + const { view, store } = mount({ summaries, diffs }) + expect(store.getSnapshot().byTab[TAB]?.index).toBe(0) + fireEvent.click(view.getByRole('button', { name: en['review.splitAria'] })) + expect(store.getSnapshot().byTab[TAB]?.split).toBe(true) + const body = view.container.querySelector('[data-review-view]') + expect(body?.getAttribute('data-review-view')).toBe('split') + const rows = [...view.container.querySelectorAll('[data-diff-line]')] + expect(rows.map(row => row.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'context', 'del', 'del']) + expect(rows[1]?.textContent).toBe('2b2B') + expect(rows[2]?.textContent).toBe('3c') + expect(rows[5]?.textContent).toBe('20z') + expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true') + expect(body?.hasAttribute('data-review-wrap')).toBe(false) + fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] })) + expect(view.container.querySelector('[data-review-view]')?.hasAttribute('data-review-wrap')).toBe(true) + expect(store.getSnapshot().byTab[TAB]).toMatchObject({ split: true, wrap: true }) + }) + + it('opens the whole file in the sidebar and the native open only with a desktop', () => { + const summaries = new ChangesSummaryStore() + summaries.state.set({ [SUMMARY_URL]: summary }) + const controller = new PresentedOpenController() + controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) + const { view, injected, tabActions } = mount({ summaries, controller, params: { index: 1 } }) + expect(injected.reloadPresentedHost).not.toHaveBeenCalled() + fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in sidebar' })) + expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin')) + fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' })) + expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1) + act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) }) + expect((view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }) as HTMLButtonElement).disabled).toBe(true) + act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) }) + expect(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }).hasAttribute('data-error')).toBe(true) + act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) }) + expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull() + act(() => { + controller.state.set({}) + controller.host.set({ name: 'server', available: false, fileManager: null }) + }) + expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull() + expect(view.getByText(en['changes.oversized'])).toBeTruthy() + }) + + it('states the summary and comparison that stand in for hunks, and retries a failed read', () => { + const summaries = new ChangesSummaryStore() + summaries.state.set({ [SUMMARY_URL]: 'missing' }) + const missing = mount({ summaries, locale: zh }) + expect(missing.view.getByText(zh['diff.missing'])).toBeTruthy() + expect(missing.view.getByText('第 2 轮改动')).toBeTruthy() + missing.view.unmount() + summaries.state.set({ [SUMMARY_URL]: summary }) + const diffs = new ChangesDiffStore() + const { view, injected } = mount({ summaries, diffs, locale: zh }) + const url = changesDiffUrl(SESSION, 5, 0) + expect(view.getByRole('status').textContent).toBe(zh['diff.loading']) + act(() => { diffs.state.set({ [url]: 'error' }) }) + fireEvent.click(view.getByRole('button', { name: zh['presented.retry'] })) + expect(injected.loadChangesDiff).toHaveBeenCalledTimes(2) + act(() => { diffs.state.set({ [url]: 'missing' }) }) + expect(view.getByText(zh['diff.missing'])).toBeTruthy() + act(() => { diffs.state.set({ [url]: { kind: 'binary', path: 'p', display: 'p' } }) }) + expect(view.getByText(zh['diff.binary'])).toBeTruthy() + act(() => { diffs.state.set({ [url]: { kind: 'oversized', path: 'p', display: 'p' } }) }) + expect(view.getByText(zh['diff.oversized'])).toBeTruthy() + act(() => { diffs.state.set({ [url]: { ...text, before: false, coarse: true } }) }) + expect(view.getByText(zh['diff.created'])).toBeTruthy() + expect(view.container.querySelector('[data-diff-coarse]')?.textContent).toBe(zh['diff.coarse']) + act(() => { diffs.state.set({ [url]: { ...text, after: false } }) }) + expect(view.getByText(zh['diff.deleted'])).toBeTruthy() + act(() => { diffs.state.set({ [url]: { ...text, hunks: [] } }) }) + expect(view.getByText(zh['diff.unchanged'])).toBeTruthy() + const long = Array.from({ length: MAX_RENDERED_LINES + 1 }, (_, at) => `+line ${at}`) + act(() => { + diffs.state.set({ [url]: { ...text, hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: long.length, lines: long }] } }) + }) + expect(view.container.querySelectorAll('[data-diff-line]')).toHaveLength(MAX_RENDERED_LINES) + expect(view.container.querySelector('[data-diff-truncated]')?.textContent).toBe(`只显示前 ${MAX_RENDERED_LINES} 行`) + }) + + it('falls back to the first file for an index the summary does not list, and forgets its state with the tab', () => { + const summaries = new ChangesSummaryStore() + summaries.state.set({ [SUMMARY_URL]: summary }) + const { view, store, aborter, injected } = mount({ summaries, params: { index: 9 } }) + expect(view.getByRole('button', { name: en['review.selectFile'] }).getAttribute('data-review-file')).toBe('src/app/main.ts') + expect(injected.loadChangesDiff).toHaveBeenLastCalledWith('viewed', 5, 0) + expect(store.getSnapshot().byTab[TAB]).toBeDefined() + aborter.abort() + expect(store.getSnapshot().byTab[TAB]).toBeUndefined() + }) + + it('refuses an address it did not mint', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + expect(() => mount({ address: 'dsh-resource://file/session/viewed/a.ts' })).toThrow('not a review address') + error.mockRestore() + }) +}) diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json index c2f4d6bec6..25c29982e6 100644 --- a/packages/client/ui-deliverables/tsconfig.client.json +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -30,6 +30,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-dockkit" + }, { "path": "../ui-chat" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b4ff77063..49ad4fc569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2993,6 +2993,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-dockkit': + specifier: workspace:^ + version: link:../ui-dockkit '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives From 91d9346aa11e2b4a62dcaaaf8ed083afc16b7947 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 16:44:52 +0800 Subject: [PATCH 28/37] fix(deliverables): open the review from the card header and scroll unwrapped split columns separately The card header opens the turn's review on its first file instead of the common folder, so the folder route and its Host lookup go away and the native open lives only in the review tab. The split view without wrapping draws each side as its own column that scrolls sideways, so a long line no longer runs under the other side; with wrapping both sides still share one row per pair. --- ...26-09-11-turn-changed-files-card.i18n.yaml | 4 +- .../2026-09-11-turn-changed-files-card.md | 4 +- .../2026-09-11-turn-changed-files-card.zh.md | 4 +- ...-09-15-changed-file-diff-preview.i18n.yaml | 4 +- .../2026-09-15-changed-file-diff-preview.md | 2 +- ...2026-09-15-changed-file-diff-preview.zh.md | 2 +- apps/web/tests/changed-files-turn.e2e.ts | 15 +++-- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 6 +- packages/client/ui-deliverables/README.zh.md | 6 +- .../client/ui-deliverables/src/changes.ts | 12 ++-- .../src/client/ChangedFiles.tsx | 58 +++++-------------- .../src/client/Deliverables.tsx | 8 +-- .../src/client/ReviewTab.module.css | 8 ++- .../ui-deliverables/src/client/ReviewTab.tsx | 46 ++++++++++++++- .../ui-deliverables/src/client/locales.ts | 8 +-- .../src/client/present-open.ts | 7 +-- .../ui-deliverables/src/present-open.ts | 35 +---------- .../tests/changes-open.host.spec.ts | 23 +------- .../tests/deliverables.client.spec.tsx | 36 ++++-------- .../tests/present-open.client.spec.ts | 9 +-- .../tests/review-tab.client.spec.tsx | 15 +++-- .../web/changed-files-turn/ui.expected.md | 2 +- 23 files changed, 135 insertions(+), 183 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index 48862b591c..376ac2b6d8 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: 4b39e2b339331bbe11ee98373707e1d7310afa3c -2026-09-11-turn-changed-files-card.zh.md: c0c6f8d161c3e87ae999b1d75a6661fc2a2a8793 +2026-09-11-turn-changed-files-card.md: 0c41783c6d5d02fecd0c85298aed1f07f5c8a65f +2026-09-11-turn-changed-files-card.zh.md: f417b31b899529b664664926c832c28f4b1be6ba diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index 4b39e2b339..0c41783c6d 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -18,7 +18,7 @@ Git is the default executable on `PATH`; no environment plugin is consulted. Out Changes outside snapshot coverage come from whole-file captures: before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the named file into the Session's temporary directory, once per path per turn, and copies it again at turn end; ignored files, files outside the work tree, and every file-tool edit without a snapshot are listed from a line comparison of the two copies ([comparison decision](2026-09-15-changed-file-diff-preview.md)). Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation. -The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. Each row opens the turn's review tab in the right Sidebar on that file ([review decision](2026-09-15-changed-file-diff-preview.md)); with a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and the review tab offers the default-application open. +The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. Each row opens the turn's review tab in the right Sidebar on that file and the header opens it on the first file ([review decision](2026-09-15-changed-file-diff-preview.md)); with a Host desktop the review tab offers the default-application open. The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered. The Client keeps the latest announcement per turn and reads its summary once through the authenticated summary route. @@ -50,4 +50,4 @@ Every turn with tool results costs two snapshots and one diff on the Host, and w The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card whenever a turn changes a file; one dedicated scenario seeds a git repository so the card also carries a shell edit, while the others list their file-tool writes alone. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. -Focused tests cover repositories with real git, directories outside any repository, coverage classification of captured paths, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores. +Focused tests cover repositories with real git, directories outside any repository, coverage classification of captured paths, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file route, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index c0c6f8d161..f417b31b89 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -18,7 +18,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 快照覆盖之外的改动来自整文件捕获:在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把所指文件复制到 Session 的临时目录,每轮每个路径一次,轮次结束时再复制一次;被忽略的文件、工作树之外的文件,以及没有快照时的每一次文件工具编辑,都由两份副本的逐行对比列出([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。 -列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。每一行在右侧 Sidebar 打开本轮的 review tab 并选中该文件([review 决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——review tab 提供用默认应用打开。 +列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。每一行在右侧 Sidebar 打开本轮的 review tab 并选中该文件,标题则在第一个文件上打开它([review 决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,review tab 提供用默认应用打开。 记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。 @@ -50,4 +50,4 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只要某一轮改动了文件就会新增该事件与卡片;一个专门的场景种入 git 仓库,让卡片也带上 shell 的改动,其余场景只列它们的文件工具写入。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 -聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、对已捕获路径的覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。 +聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、对已捕获路径的覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。 diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml index 310c82b3f3..bb657950e7 100644 --- a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.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-09-15-changed-file-diff-preview.md -2026-09-15-changed-file-diff-preview.md: f939fe224899d8329b4a9c2d8fdc267f250403da -2026-09-15-changed-file-diff-preview.zh.md: 688bd9c841cda406f2b53825203753aaad4d6004 +2026-09-15-changed-file-diff-preview.md: e265c1f4528f2de3a95ce0fab3e20cab1c83a3f6 +2026-09-15-changed-file-diff-preview.zh.md: 48571cd9866cb01e31609c90500d26b19802bb8e diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md index f939fe2248..e265c1f452 100644 --- a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md @@ -10,7 +10,7 @@ The [changed-files card](2026-09-11-turn-changed-files-card.md) tells the user w ## Decision -Each card row opens the turn's `changes-review` tab in the right Sidebar on that file: one tab per turn, addressed by the viewed Session and the announcing event's sequence, with a file selector in its header that lists every recorded file and shows one file's turn-start and turn-end comparison at a time. The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) recorder serves each comparison through `workspaceChanges.diff(sessionId, seq, index, signal)`; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) registers the tab type, passes the row's file index as a navigation parameter, and reads the summary and the comparisons through authenticated routes. The header's tools switch between the unified view and a side-by-side view, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop, open it in the default application; view and wrap choices are kept per tab. Rows open the review with and without a desktop, so the earlier row-level native open moves into the tab and the card keeps one behavior. +Each card row opens the turn's `changes-review` tab in the right Sidebar on that file, and the card's header opens it on the first file: one tab per turn, addressed by the viewed Session and the announcing event's sequence, with a file selector in its header that lists every recorded file and shows one file's turn-start and turn-end comparison at a time. The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) recorder serves each comparison through `workspaceChanges.diff(sessionId, seq, index, signal)`; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) registers the tab type, passes the row's file index as a navigation parameter, and reads the summary and the comparisons through authenticated routes. The header's tools switch between the unified view and a side-by-side view, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop, open it in the default application; view and wrap choices are kept per tab. Rows and the header open the review with and without a desktop, so the earlier row-level native open and the header's folder open move into the tab and the card keeps one behavior. Files git does not cover are compared the way Codex's turn diff tracker compares its `apply_patch` edits: from whole-file copies, not from hunks. Before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder's `tools/pre-execute` step, which already waits for the baseline snapshot, copies the named file into the Session's temporary directory beside the snapshot objects, once per path per turn; at turn end it copies the path again. The copies are named by the SHA-1 of their bytes, so identical content is stored once, and they need no git. Paths the snapshots cover keep their git counts; every other captured path is listed from a line comparison of its two copies, which counts a repeatedly edited line once and includes a shell edit made after the file-tool edit. The persisted hunks and the argument-derived hunks are no longer read by the recorder. diff --git a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md index 688bd9c841..48571cd986 100644 --- a/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -卡片的每一行在右侧 Sidebar 打开本轮的 `changes-review` tab 并选中该文件:每轮一个 tab,由当前查看的 Session 和宣告事件的序号定址,头部有一个列出所有记录文件的文件选择器,一次显示一个文件在轮次开始与结束时的对比。Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 记录器通过 `workspaceChanges.diff(sessionId, seq, index, signal)` 提供每个对比;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)注册该 tab 类型,把行的文件下标作为导航参数传入,并通过经过认证的路由读取摘要和对比。头部的工具在单栏和左右视图之间切换、切换自动换行、在 Sidebar 中打开当前整个文件,有 Host 桌面时用默认应用打开它;视图与换行的选择按 tab 保留。有没有桌面,行都打开 review,先前行级别的原生打开移进 tab,卡片只保留一种行为。 +卡片的每一行在右侧 Sidebar 打开本轮的 `changes-review` tab 并选中该文件,卡片头部则在第一个文件上打开它:每轮一个 tab,由当前查看的 Session 和宣告事件的序号定址,头部有一个列出所有记录文件的文件选择器,一次显示一个文件在轮次开始与结束时的对比。Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 记录器通过 `workspaceChanges.diff(sessionId, seq, index, signal)` 提供每个对比;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)注册该 tab 类型,把行的文件下标作为导航参数传入,并通过经过认证的路由读取摘要和对比。头部的工具在单栏和左右视图之间切换、切换自动换行、在 Sidebar 中打开当前整个文件,有 Host 桌面时用默认应用打开它;视图与换行的选择按 tab 保留。有没有桌面,行和头部都打开 review,先前行级别的原生打开和头部的打开文件夹都移进 tab,卡片只保留一种行为。 git 覆盖不到的文件按 Codex 的 turn diff tracker 对比其 `apply_patch` 编辑的方式对比:用整文件副本,不用 hunk。在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器本来就要等待基线快照的 `tools/pre-execute` 步骤把所指文件复制到 Session 临时目录里快照对象旁边,每轮每个路径一次;轮次结束时再复制一次该路径。副本按其字节的 SHA-1 命名,相同内容只存一份,且不需要 git。快照覆盖到的路径保留 git 的行数;其余每个被捕获的路径由两份副本的逐行对比列出,反复编辑的行只计一次,文件工具编辑之后的 shell 改动也包含在内。记录器不再读取持久化的 hunk 和由参数推出的 hunk。 diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 6084e407e7..08045544fc 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -110,8 +110,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1) expect(await card.getByRole('listitem').count()).toBe(3) expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1) - // Without a Host desktop the header is a label; every row opens its comparison in the Sidebar. - expect(await card.getByRole('button', { name: '打开改动文件所在的文件夹' }).count()).toBe(0) + // The header and every row open the turn's review in the Sidebar, with or without a Host desktop. + expect(await card.getByRole('button', { name: '在侧边栏查看本轮改动' }).count()).toBe(1) expect(await card.getByRole('button', { name: '查看 notes.txt 的改动' }).count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -122,8 +122,11 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { const column = page.locator('[data-rightbar-col]') const drawn = (root: ReturnType) => root.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`)) - await card.getByRole('button', { name: '查看 notes.txt 的改动' }).click() const review = column.locator('[data-changes-review]') + // The header lands on the first listed file; a row lands on its own. + await card.getByRole('button', { name: '在侧边栏查看本轮改动' }).click() + await review.locator('[data-review-file="app.local"]').waitFor({ state: 'visible' }) + await card.getByRole('button', { name: '查看 notes.txt 的改动' }).click() await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' }) expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1) await expect.poll(() => drawn(review)).toEqual(['context:11 start', 'add:2+done']) @@ -139,9 +142,11 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1) await review.getByRole('button', { name: '左右对比' }).click() await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' }) - await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。']) + await expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['del:1# 示例项目', 'context:2', 'context:3一个用于演示的仓库。']) + expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['del:1# 项目说明', 'context:2', 'context:3一个用于演示的仓库。']) await review.getByRole('button', { name: '自动换行' }).click() - expect(await review.locator('[data-review-view][data-review-wrap]').count()).toBe(1) + await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' }) + await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。']) // No desktop, so the tools offer the sidebar file but no native open. expect(await review.locator('[data-review-tool="open-file"]').count()).toBe(1) expect(await review.locator('[data-review-tool="open-native"]').count()).toBe(0) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 08eeb2a50d..bcec6615fe 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/ui-deliverables/README.md -README.md: ab9e25c7b351a8eb82461543654c28155945a63b -README.zh.md: 194d92305fe30e1836838b3af73172b00608a1a6 +README.md: b0137675a207a7aaee3d38060319d5948c8ba432 +README.zh.md: 8dfc6767919ae186962d49da393ef8316f62c417 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index ab9e25c7b3..b0137675a2 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -36,7 +36,7 @@ The `present` tool row shows running, delivered, failed, or interrupted status; ### The changed-files card -The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager through an authenticated Host action that shows the delivery cards' pending and retryable-error states in place of the totals; a completed open shows the totals again. Without a desktop the header is a plain label. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. +The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. ### The review tab @@ -56,7 +56,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session///` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory, and the comparison tab's native open uses the same indexed coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the review tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the review tab's native open of a changed file uses the same coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the review tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. @@ -103,7 +103,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. -- **Folder and native opening need a Host desktop** — without one the card header is inert and the review tab offers no native open; the comparison itself needs only the Host that recorded the turn. +- **Native opening needs a Host desktop** — without one the review tab offers no native open; the comparison itself needs only the Host that recorded the turn. - **Comparisons are unhighlighted** — the tab shows plain hunks without syntax colouring, and draws at most 5,000 lines before saying so. - **Comparisons carry whole file text** — the comparison route serves a listed file's complete text wherever the Host recorded it, including ignored files and files outside the workspace root the Sidebar previews are confined to. - **Package-local header glyph** — the card's angle-bracket mark lives in `src/client/icons.tsx` until the shared icon set carries it; its props already match the shared icon contract. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 194d92305f..8dfc676791 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -36,7 +36,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 ### 改动文件卡片 -卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件。Host 有桌面时,标题通过经过认证的 Host 操作在文件管理器中打开包含所列文件的最深工作区文件夹,并在合计位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示合计。没有桌面时标题是普通标签。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 +卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 ### review tab @@ -56,7 +56,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session///`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录;对比 tab 的原生打开使用同一组带下标的坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;review tab 对改动文件的原生打开使用同一组坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 @@ -103,7 +103,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 -- **打开文件夹和原生打开需要 Host 桌面**——没有桌面时卡片标题不可点击,review tab 也不提供原生打开;对比本身只需要记录了该轮的 Host。 +- **原生打开需要 Host 桌面**——没有桌面时 review tab 不提供原生打开;对比本身只需要记录了该轮的 Host。 - **对比没有语法高亮**——tab 只显示纯文本 hunk,最多绘制 5000 行并给出提示。 - **对比携带整个文件的文本**——对比路由会送出所列文件在 Host 上记录时的完整文本,包括被忽略的文件和 Sidebar 预览所限定的工作区根目录之外的文件。 - **包内的标题字形**——卡片的尖括号标记放在 `src/client/icons.tsx` 中,直到共享图标集收录它;其 props 已与共享图标契约一致。 diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts index 450a7ea29a..29b0da9faf 100644 --- a/packages/client/ui-deliverables/src/changes.ts +++ b/packages/client/ui-deliverables/src/changes.ts @@ -8,7 +8,7 @@ export const CHANGED_FILES_PATH = '/api/changes.summary' /** Authenticated GET route serving one listed file's turn-start and turn-end comparison while its Session lives. */ export const CHANGES_DIFF_PATH = '/api/changes.diff' -/** Authenticated POST route for opening a changed file, or the changed files' common folder, on the Host desktop. */ +/** Authenticated POST route for opening a changed file on the Host desktop. */ export const CHANGES_OPEN_PATH = '/api/changes.open' /** Resource-address prefix of a turn's review tab in the right Sidebar. */ @@ -112,16 +112,14 @@ export function changesDiffUrl(sessionId: SessionId, seq: number, index: number) } /** - * Build authenticated coordinates for a changed file or the changed files' common folder. + * Build authenticated coordinates for a changed file's native open. * @param sessionId - owning Session. * @param seq - workspace/changes event sequence. - * @param index - original index in the summary's files array, or null for the common folder. + * @param index - original index in the summary's files array. * @returns same-origin action URL. */ -export function changedFileUrl(sessionId: SessionId, seq: number, index: number | null): string { - const query = new URLSearchParams({ sessionId, seq: String(seq) }) - if (index !== null) query.set('index', String(index)) - return `${CHANGES_OPEN_PATH}?${query}` +export function changedFileUrl(sessionId: SessionId, seq: number, index: number): string { + return `${CHANGES_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` } /** diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index fe7be9747d..cb0d5c1a05 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,13 +1,10 @@ -/** The changed-files card: a folder-opening header, per-file line counts opening the turn's review on that file, and a three-row fold. */ +/** The changed-files card: a header and per-file rows that open the turn's review, and a three-row fold. */ import { useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { changedFileUrl, type ChangesSummary } from '../changes.ts' -import type { PresentedHost } from '../presented.ts' +import type { ChangesSummary } from '../changes.ts' import { IconCodeBracketsOutline16 } from './icons.tsx' -import type { PresentedOpenPhase } from './present-open.ts' import type { NS } from './locales.ts' import css from './ChangedFiles.module.css' @@ -16,20 +13,6 @@ const COLLAPSED_ROWS = 3 const GROUPED = new Intl.NumberFormat('en-US') -/** - * Folder gesture state worth showing in place of the totals: pending, or - * failed. A completed open shows the totals again. The card never reveals, so - * only open phases occur. - */ -function gesture(phase: PresentedOpenPhase | undefined): { key: 'presented.opening' | 'presented.error' | 'presented.nativeUnavailable'; failed: boolean } | undefined { - switch (phase) { - case 'opening': return { key: 'presented.opening', failed: false } - case 'error': return { key: 'presented.error', failed: true } - case 'nativeUnavailable': return { key: 'presented.nativeUnavailable', failed: true } - default: return undefined - } -} - /** Added and deleted line counts in the card's colors. */ function Counts({ added, deleted, t }: { added: number; deleted: number } & PropsLocale) { return <> @@ -39,44 +22,29 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop } /** - * Render one turn's changed files. Each row opens the turn's review in the - * right Sidebar on that file; the header opens the files' common folder only - * with a Host desktop. - * @param props - the recorded summary, Host capabilities, folder gesture status, openers, and localized copy. + * Render one turn's changed files. The header opens the turn's review in the + * right Sidebar on its first file; each row opens it on that row's file. + * @param props - the recorded summary, the review opener, and localized copy. * @returns the card. */ -export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openReview, t }: { +export function ChangedFiles({ changes, cwd, openReview, t }: { /** The served summary with the sequence of the event that announced it. */ changes: Pick & { seq: number } cwd: string | undefined - sessionId: SessionId - host: PresentedHost | null - phases: Record - onOpen: (index: number | null) => void /** Open the turn's review on the file at an original summary index. */ openReview: (index: number) => void } & PropsLocale) { const [expanded, setExpanded] = useState(false) - const native = host !== null && host.available const foldable = changes.files.length > COLLAPSED_ROWS const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files - const folder = gesture(phases[changedFileUrl(sessionId, changes.seq, null)]) - const summary = <> - - - {t('changes.title', { count: String(changes.total) })} - - {folder === undefined - ? - : t(folder.failed ? 'changes.folderError' : 'changes.folderOpening')} - - - return
    - {native - ? - :
    {summary}
    } +
      {rows.map((file, index) => (
    • diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 0e0f2041f4..a7ad63dc03 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -52,7 +52,7 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch * @returns the closing turn's file rows. */ export function Deliverables({ - matched, openFile, t, sessionId, useSessions, openPresented, openChanged, openChangesReview, usePresentedOpen, usePresentedHost, + matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost, useChangesSummary, reloadPresentedHost, loadChangesSummary, }: Pick & { matched: DeliverablesMatch @@ -77,10 +77,8 @@ export function Deliverables({ if (host === null) void reloadPresentedHost() }, [host, reloadPresentedHost]) return <> - {changes !== null && { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }} - onOpen={(index) => { void openChanged(sessionId, changes.seq, index) }} />} + {changes !== null && { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }} />} {matched.presented.length > 0 &&
      } +/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */ +function splitRowKind(row: SplitRow): DiffRow['kind'] { + return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context' +} + +function hunkHeader(hunk: WorkspaceDiffHunk): string { + return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` +} + +/** + * The side-by-side view without wrapping: two columns that scroll sideways on + * their own, so a long line on one side never runs under the other. Every + * line is one fixed-height row, which keeps the sides aligned. + */ +function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): ReactNode { + const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) + return ( +
      + {(['left', 'right'] as const).map(side => ( +
      + {paired.map((hunk, position) => ( +
      +
      {hunk.header}
      + {hunk.rows.map((row, at) => { + const cell = row[side] + return ( +
      + {cell?.no ?? ''} + {cell?.text ?? ''} +
      + ) + })} +
      + ))} +
      + ))} +
      + ) +} + /** The hunks of a text comparison with their line numbers, unified or side by side. */ function TextDiff({ diff, split, wrap, t }: { diff: Extract; split: boolean; wrap: boolean } & PropsLocale): ReactNode { const note = noteOf(diff) @@ -270,11 +310,11 @@ function TextDiff({ diff, split, wrap, t }: { diff: Extract{t(note)}

      } {diff.coarse &&

      {t('diff.coarse')}

      } {truncated &&

      {t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

      } - {hunks.map((hunk, position) => ( + {split && !wrap ? : hunks.map((hunk, position) => (
      -
      {`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`}
      +
      {hunkHeader(hunk)}
      {split ? splitRows(hunk).map((row, at) => ( -
      +
      {row.left?.no ?? ''} {row.left?.text ?? ''} diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 4cdbcc448a..7eacc52c34 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -42,13 +42,11 @@ export const zh = { 'changes.added': '+{count}', 'changes.deleted': '-{count}', 'changes.binary': '二进制', - 'changes.openFolder': '打开改动文件所在的文件夹', + 'changes.openReview': '在侧边栏查看本轮改动', 'changes.all': '全部 {count} 个文件', 'changes.expandAria': '展开全部 {count} 个改动文件', 'changes.collapse': '收起', 'changes.collapseAria': '收起改动文件列表', - 'changes.folderOpening': '正在打开文件夹…', - 'changes.folderError': '无法打开文件夹,点击重试', 'changes.oversized': '过大', 'changes.viewDiff': '查看 {name} 的改动', 'review.title': '第 {turn} 轮改动', @@ -115,13 +113,11 @@ export const en: Record = { 'changes.added': '+{count}', 'changes.deleted': '-{count}', 'changes.binary': 'binary', - 'changes.openFolder': 'Open the folder containing the changed files', + 'changes.openReview': 'Review this turn’s changes in the sidebar', 'changes.all': 'All {count} files', 'changes.expandAria': 'Show all {count} changed files', 'changes.collapse': 'Collapse', 'changes.collapseAria': 'Collapse changed files', - 'changes.folderOpening': 'Opening folder…', - 'changes.folderError': 'Could not open folder. Click to retry.', 'changes.oversized': 'too large', 'changes.viewDiff': 'View changes to {name}', 'review.title': 'Review · turn {turn}', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 8955cdfc71..aef36604aa 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -32,14 +32,13 @@ export class PresentedOpenController { } /** - * Open one recorded changed file, or the changed files' common folder, in - * the Host's default application. + * Open one recorded changed file in the Host's default application. * @param sessionId - viewed Session. * @param seq - durable workspace/changes event sequence. - * @param index - original file index within that event, or null for the common folder. + * @param index - original file index within that event. * @returns after the Host acknowledges opening or the error state is published. */ - openChanged(sessionId: SessionId, seq: number, index: number | null): Promise { + openChanged(sessionId: SessionId, seq: number, index: number): Promise { return this.openUrl(changedFileUrl(sessionId, seq, index), 'open') } diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 067a1b26b6..233f58538e 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -1,5 +1,4 @@ -/** Serve change summaries and comparisons, and open declared or changed workspace paths verified by the viewed Session's filesystem. */ -import { dirname, isAbsolute, relative, resolve } from 'node:path' +/** Serve change summaries and comparisons, and open declared or changed workspace files verified by the viewed Session's filesystem. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller' import type {} from '@deepseek-ai/dsh-api-workspace-files' @@ -9,14 +8,13 @@ import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' -import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' import { CHANGES_DIFF_PATH, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, type ChangesSummary } from './changes.ts' import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts' /** * Register the deliverables routes inside Connection's authentication fence: * desktop metadata, change summaries and comparisons, declared-file actions, - * and changed-file or folder opening. + * and changed-file opening. * @param ctx - Session lookup, change summaries, native opener, and route lifetime. */ export function registerPresentOpen(ctx: Context): void { @@ -123,26 +121,6 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise { it('opens a listed file inside or outside the workspace with its verified Host path', async () => { const { cwd, open, opener, outside } = await fixture() expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9&index=0`) - expect(changedFileUrl(SessionId('owner'), 9, null)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9`) const response = await open() expect(response.status).toBe(204) expect(response.headers.get('cache-control')).toBe('no-store') @@ -142,20 +141,7 @@ describe('changed files native open route', () => { expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside)) }) - it('opens the deepest folder containing the workspace files, falling back to the workspace', async () => { - const { cwd, open, opener, data } = await fixture() - expect((await open('?sessionId=owner&seq=9')).status).toBe(204) - expect(opener).toHaveBeenLastCalledWith({ path: await realpath(join(cwd, 'src')) }, expect.any(AbortSignal)) - data.files = [changed('../escaped.ts', '../escaped.ts'), changed('/etc/hosts', '/etc/hosts')] - expect((await open('?sessionId=owner&seq=9')).status).toBe(204) - expect(opener.mock.lastCall?.[0].path).toBe(await realpath(cwd)) - const w = resolve('/w') - expect(commonChangedFolder(w, [changed('a/b/c.ts'), changed('a/d.ts'), changed(resolve('/x/y.ts'))])).toBe(resolve(w, 'a')) - expect(commonChangedFolder(w, [changed(resolve('/x/y.ts'))])).toBe(w) - expect(commonChangedFolder(w, [changed('../up.ts')])).toBe(w) - }) - - it.each(['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=9&index=-1', '?sessionId=owner&seq=9&index=1.5', '?sessionId=owner&seq=x'])( + it.each(['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=9', '?sessionId=owner&seq=9&index=-1', '?sessionId=owner&seq=9&index=1.5', '?sessionId=owner&seq=x'])( 'rejects invalid coordinates before reading: %s', async (query) => { const { open, readEvent } = await fixture() expect((await open(query)).status).toBe(400) @@ -170,8 +156,6 @@ describe('changed files native open route', () => { expect(readEvent).not.toHaveBeenCalled() await unlink(join(cwd, 'src', 'lib', 'a.ts')) expect((await open()).status).toBe(404) - await rm(join(cwd, 'src'), { recursive: true }) - expect((await open('?sessionId=owner&seq=9')).status).toBe(404) expect(opener).not.toHaveBeenCalled() }) @@ -182,7 +166,6 @@ describe('changed files native open route', () => { desktop.mockRestore() const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) expect((await open()).status).toBe(422) - expect((await open('?sessionId=owner&seq=9')).status).toBe(422) mapping.mockRestore() opener.mockRejectedValueOnce(new Error('/private/host/path')) const failed = await open() diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 574b722ce7..36ae495709 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -567,7 +567,7 @@ describe('ChangedFiles card', () => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading') }) - it('summarizes the turn, folds after three rows, opens the review on each row, and opens the folder natively', () => { + it('summarizes the turn, folds after three rows, and opens the review from the header and each row', () => { const { props, openFile, view } = renderCard() const card = view.container.querySelector('[data-changed-files]') if (!(card instanceof HTMLElement)) throw new Error('changed-files card missing') @@ -581,8 +581,8 @@ describe('ChangedFiles card', () => { fireEvent.click(within(card).getByRole('button', { name: 'View changes to config/feature-flags.json' })) expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 1) expect(props.openChanged).not.toHaveBeenCalled() - fireEvent.click(within(card).getByRole('button', { name: 'Open the folder containing the changed files' })) - expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, null) + fireEvent.click(within(card).getByRole('button', { name: 'Review this turn’s changes in the sidebar' })) + expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 0) expect(openFile).not.toHaveBeenCalled() const expand = within(card).getByRole('button', { name: 'Show all 5 changed files' }) expect(expand.getAttribute('aria-expanded')).toBe('false') @@ -600,16 +600,17 @@ describe('ChangedFiles card', () => { expect(within(card).getAllByRole('listitem')).toHaveLength(3) }) - it('still opens the review on each row and offers no folder action without a desktop', () => { + it('opens the review the same way without a desktop', () => { const controller = new PresentedOpenController() const { openFile, props, view } = renderCard(controller, zh) controller.host.set('error') view.rerender() - expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull() + expect(view.getByRole('button', { name: '在侧边栏查看本轮改动' })).toBeTruthy() controller.host.set({ name: 'server', available: false, fileManager: null }) view.rerender() expect(view.getByText('已编辑 11 个文件')).toBeTruthy() - expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull() + fireEvent.click(view.getByRole('button', { name: '在侧边栏查看本轮改动' })) + expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 0) fireEvent.click(view.getByRole('button', { name: '查看 config/design-token 的改动' })) expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 0) expect(openFile).not.toHaveBeenCalled() @@ -617,34 +618,19 @@ describe('ChangedFiles card', () => { expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件') }) - it('shows folder gesture states in place of the totals and keeps row counts through native opens', () => { + it('keeps every count in place whatever the native-open gestures of the review tab are doing', () => { const controller = new PresentedOpenController() controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opening', '/api/changes.open?sessionId=child-session&seq=5&index=1': 'error', - '/api/changes.open?sessionId=child-session&seq=5': 'opened', }) const { view } = renderCard(controller) - // Row gestures belong to the comparison tab; the card keeps every row's counts. + // Native-open gestures belong to the review tab; the card shows counts only. expect(view.queryByText(en['presented.opening'])).toBeNull() expect(view.queryByText(en['presented.error'])).toBeNull() expect(view.getByText('+42')).toBeTruthy() expect(view.getByText('+143')).toBeTruthy() - // A completed folder open leaves the summed counts in place. expect(view.getByText('+1,232')).toBeTruthy() - view.unmount() - controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'opening' }) - const { view: pending } = renderCard(controller) - expect(pending.getByText(en['changes.folderOpening'])).toBeTruthy() - expect((pending.getByRole('button', { name: 'Open the folder containing the changed files' }) as HTMLButtonElement).disabled).toBe(true) - pending.unmount() - controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'error' }) - const { view: failed } = renderCard(controller) - expect(failed.getByText(en['changes.folderError']).closest('[data-error]')).toBeTruthy() - failed.unmount() - controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'nativeUnavailable' }) - const { view: unmapped } = renderCard(controller) - expect(unmapped.getByText(en['changes.folderError']).closest('[data-error]')).toBeTruthy() }) it('renders without a fold for three files or fewer and beside delivery cards', () => { @@ -768,8 +754,8 @@ describe('plugin registration', () => { expect(face.hooks.changesSummary.getSnapshot()).toEqual({}) await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') - await face.openChanged(SessionId('child-session'), 5, null) - expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5']).toBe('opened') + await face.openChanged(SessionId('child-session'), 5, 0) + expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5&index=0']).toBe('opened') face.openChangesReview({ sessionId: SessionId('child-session'), seq: 5, turn: 3 }, 1) expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-review/session/child-session/5/3', { params: { index: 1 } }) expect((registered as { title(address: string): string }).title('dsh-resource://changes-review/session/child-session/5/3')).toBe('Review · turn 3') diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index 1d92e2cef9..0b142a5fca 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -26,16 +26,13 @@ it('coalesces concurrent card and mention gestures, then allows another open', a await controller.dispose() }) -it('opens changed files and their common folder through their own coordinates', async () => { +it('opens changed files through their own coordinates', async () => { const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise>().mockResolvedValue(new Response(null, { status: 204 })) vi.stubGlobal('fetch', fetcher) const controller = new PresentedOpenController() await controller.openChanged(id, 9, 1) - await controller.openChanged(id, 9, null) - expect(fetcher.mock.calls.map(call => call[0])).toEqual([ - '/api/changes.open?sessionId=fork&seq=9&index=1', '/api/changes.open?sessionId=fork&seq=9', - ]) - expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9']).toBe('opened') + expect(fetcher.mock.calls.map(call => call[0])).toEqual(['/api/changes.open?sessionId=fork&seq=9&index=1']) + expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened') fetcher.mockResolvedValueOnce(new Response(null, { status: 422 })) await controller.openChanged(id, 9, 1) expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable') diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 084b0205e1..c63b0b2910 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -232,15 +232,22 @@ describe('ReviewTab', () => { expect(store.getSnapshot().byTab[TAB]?.split).toBe(true) const body = view.container.querySelector('[data-review-view]') expect(body?.getAttribute('data-review-view')).toBe('split') + expect(body?.hasAttribute('data-review-wrap')).toBe(false) + // Without wrapping each side is its own column, so a long line scrolls within its side. + const side = (name: string) => [...view.container.querySelectorAll(`[data-diff-side="${name}"] [data-diff-line]`)] + expect(side('left').map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'context', 'del', 'del']) + expect(side('left').map(line => line.textContent)).toEqual(['1a', '2b', '', '3d', '10x', '20z']) + expect(side('right').map(line => line.textContent)).toEqual(['1a', '2B', '3c', '4d', '11y', '']) + expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true') + // Wrapped lines vary in height, so both sides share one row per pair. + fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] })) + expect(view.container.querySelector('[data-review-view]')?.hasAttribute('data-review-wrap')).toBe(true) + expect(view.container.querySelectorAll('[data-diff-side]')).toHaveLength(0) const rows = [...view.container.querySelectorAll('[data-diff-line]')] expect(rows.map(row => row.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'context', 'del', 'del']) expect(rows[1]?.textContent).toBe('2b2B') expect(rows[2]?.textContent).toBe('3c') expect(rows[5]?.textContent).toBe('20z') - expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true') - expect(body?.hasAttribute('data-review-wrap')).toBe(false) - fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] })) - expect(view.container.querySelector('[data-review-view]')?.hasAttribute('data-review-wrap')).toBe(true) expect(store.getSnapshot().byTab[TAB]).toMatchObject({ split: true, wrap: true }) }) diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index d78a2bd927..9854b70b13 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -102,7 +102,7 @@ - code: - button "在侧边栏打开 notes.txt": notes.txt - text: 的追加则完全走 bash。 -- text: 已编辑 4 个文件 +6 -1 +- button "在侧边栏查看本轮改动": 已编辑 4 个文件 +6 -1 - list: - listitem: - button "查看 app.local 的改动": app.local +1 -0 From 26e2196a8629560dd7992b0a02f8bde156a525e4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 16:53:44 +0800 Subject: [PATCH 29/37] fix(deliverables): scroll the split review columns sideways together --- .../src/client/ReviewTab.module.css | 2 +- .../ui-deliverables/src/client/ReviewTab.tsx | 20 +++++++++++++------ .../tests/review-tab.client.spec.tsx | 8 ++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index be21362b47..5ec23f198b 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -1,4 +1,4 @@ -/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns scroll sideways on their own; wrapped ones share rows so the sides stay aligned. */ +/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll sideways together; wrapped ones share rows so the sides stay aligned. */ .root { display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } .header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } .selector { flex: 0 1 auto; min-width: 0; } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 04e845aacb..35a4350e6c 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -3,8 +3,8 @@ * selected file's turn-start and turn-end comparison drawn unified or side by * side, wrapped or scrolling, and controls to open the file itself. */ -import { useEffect, useMemo, useState } from 'react' -import type { ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode, UIEvent } from 'react' import { Button, IconChevronDownOutline14, IconCodeOutline16, IconPanelLeftOutline16, IconRightUpOutline16, IconWrapLinesOutline16, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -271,16 +271,24 @@ function hunkHeader(hunk: WorkspaceDiffHunk): string { } /** - * The side-by-side view without wrapping: two columns that scroll sideways on - * their own, so a long line on one side never runs under the other. Every - * line is one fixed-height row, which keeps the sides aligned. + * The side-by-side view without wrapping: two columns that clip their long + * lines and scroll sideways together, so a long line on one side never runs + * under the other and both sides show the same columns of text. Every line is + * one fixed-height row, which keeps the sides aligned. */ function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): ReactNode { const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) + const columns = useRef>({ left: null, right: null }) + // Mirror one side's horizontal offset onto the other; the mirrored side's own scroll event then finds nothing to change. + const follow = (side: 'left' | 'right') => (event: UIEvent): void => { + const other = columns.current[side === 'left' ? 'right' : 'left'] + if (other !== null && other.scrollLeft !== event.currentTarget.scrollLeft) other.scrollLeft = event.currentTarget.scrollLeft + } return (
      {(['left', 'right'] as const).map(side => ( -
      +
      { columns.current[side] = element }} onScroll={follow(side)}> {paired.map((hunk, position) => (
      {hunk.header}
      diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index c63b0b2910..84e76ddc3a 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -238,6 +238,14 @@ describe('ReviewTab', () => { expect(side('left').map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'context', 'del', 'del']) expect(side('left').map(line => line.textContent)).toEqual(['1a', '2b', '', '3d', '10x', '20z']) expect(side('right').map(line => line.textContent)).toEqual(['1a', '2B', '3c', '4d', '11y', '']) + // The two sides scroll sideways together, whichever side the reader drags. + const [left, right] = ['left', 'right'].map(name => view.container.querySelector(`[data-diff-side="${name}"]`) as HTMLDivElement) + fireEvent.scroll(left!, { target: { scrollLeft: 40 } }) + expect(right!.scrollLeft).toBe(40) + fireEvent.scroll(right!, { target: { scrollLeft: 15 } }) + expect(left!.scrollLeft).toBe(15) + fireEvent.scroll(right!, { target: { scrollLeft: 15 } }) + expect(left!.scrollLeft).toBe(15) expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true') // Wrapped lines vary in height, so both sides share one row per pair. fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] })) From 7f2d7b1791b8def05fbd74443652cbb69464928a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 17:10:10 +0800 Subject: [PATCH 30/37] fix(web): allow opening the sidebar before the first message --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 2 + ...nt-session-scope-and-provide-channel.zh.md | 2 + .../tests/details-session-lifecycle.e2e.ts | 68 ++++++++++++++++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../skeleton/ConversationRoot.module.css | 21 +++--- .../client/skeleton/ConversationSession.tsx | 20 ++---- .../tests/skeleton.client.spec.tsx | 8 ++- .../blank-session.expected.md | 25 +++++++ 11 files changed, 122 insertions(+), 36 deletions(-) create mode 100644 snapshots/web/details-session-lifecycle/blank-session.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index f9a0dfdce3..6e4befba1f 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 620102f9f5e7fd76f38bf0031b856b26c2a7840d -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 3f00658c672908ba92627416ba5d5db381c0d9cd +2026-07-25-web-client-session-scope-and-provide-channel.md: 0f003edc4a53e7a04ea4f5613abe42c2d3926566 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1bae16cfaa1c54eebc6c8709ac6db5323b68d222 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 620102f9f5..0f003edc4a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -95,6 +95,8 @@ Slot scope is the closed set `root | session-maybe | session`: `conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn. + - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing. - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). - Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 3f00658c67..1bae16cfaa 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -95,6 +95,8 @@ slot scope 是闭集 `root | session-maybe | session`: `conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无会话 → blank 会话的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。 + - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 - 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。 diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index a02bafac00..06f05acdc2 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -1,5 +1,5 @@ // Recorded-session Sidebar geometry and per-Session view state through the shipped browser composition. -import { mkdir, readFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Locator, Page } from 'playwright' @@ -15,6 +15,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/details-session-lifecycle', import.meta.url)) const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md') const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') +const BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-session.expected.md') const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)) const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) @@ -127,17 +128,20 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S let browser: Browser let page: Page let tripwire: ReturnType + const sessionEvents: string[] = [] beforeAll(async () => { const fixture = await readFile(FIXTURE, 'utf8') expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false }) await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed') + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event.type) }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await appFrame(page).waitFor({ timeout: 30_000 }) + expect(await page.locator('[data-sidebar-right-expand]').count()).toBe(0) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) @@ -154,12 +158,72 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await mkdir(SHOT_DIR, { recursive: true }) await saveFailureShot(page, `screenshots/0907-sidebar-rules/details-session-lifecycle-${MODE}-${process.pid}`) }) + const blankColumn = page.locator('[data-rightbar-col]') + const workspace = join(scaffold.workspaceCwd, 'workspace') + await writeFile(join(workspace, 'before-chat.md'), '# Before the first message\n\nWorkspace preview is available.\n') + await page.locator('[data-sidebar-right-expand]').waitFor({ state: 'visible' }) + await page.screenshot({ path: join(SHOT_DIR, `blank-collapsed-${MODE}-${process.pid}.png`), fullPage: true }) + await page.locator('[data-sidebar-right-expand]').click() + await blankColumn.locator('[data-sidebar-right-guide-entry="files"]').click() + await blankColumn.locator('[data-files-entry="file"]').getByRole('button', { name: 'before-chat.md', exact: true }).click() + await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor() + await page.getByText('Into the Unknown', { exact: false }).waitFor() + const blankPanes = await paneSnapshot(page) + expect(blankPanes.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'before-chat.md']]) + await page.screenshot({ path: join(SHOT_DIR, `blank-preview-${MODE}-${process.pid}.png`), fullPage: true }) + + const blankViewport = page.viewportSize()! + try { + await blankColumn.locator('[data-sidebar-right-toggle]').click() + await page.setViewportSize({ width: 767, height: blankViewport.height }) + await page.locator('[data-sidebar-right-expand]').click() + await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox()) + .toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height }) + await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor() + await blankColumn.locator('[data-sidebar-right-toggle]').click() + } finally { + await page.setViewportSize(blankViewport) + } + await page.locator('[data-sidebar-right-expand]').click() + await blankColumn.locator('[data-dockkit-add-tab]').click() + await blankColumn.locator('[data-sidebar-right-guide-entry="terminal"]') + .getByRole('button', { name: /^New terminal/u }).click() + const agent = scaffold.ctx.agents.list().find(agent => agent.session.header.cwd === workspace) + if (agent === undefined) throw new Error('Blank Session has no workspace Agent') + await expect.poll(() => scaffold.ctx.terminalController.list(agent.id).map(terminal => terminal.state)).toEqual(['running']) + await page.locator('.xterm-helper-textarea:visible').click() + await page.keyboard.insertText('node -e "require(\'fs\').writeFileSync(\'before-chat-terminal.txt\',\'READY\')"') + await page.keyboard.press('Enter') + await expect.poll(() => readFile(join(workspace, 'before-chat-terminal.txt'), 'utf8')).toBe('READY') + expect(sessionEvents).not.toContain('turn/start') + expect(sessionEvents).not.toContain('user/message') + await blankColumn.locator('[data-dockkit-tab][aria-selected="true"]').hover() + await blankColumn.locator('[data-dockkit-tab][aria-selected="true"] [data-dockkit-tab-close]').click() + await expect.poll(() => scaffold.ctx.terminalController.list(agent.id)).toEqual([]) + await blankColumn.locator('[data-dockkit-tab]').filter({ hasText: 'before-chat.md' }).click() + await compareOrRefreshGolden(BLANK_EXPECTED, [ + '# Blank Session workspace sidebar', '', + '- No selected Session: expand control absent', + '- Selected workspace before first message: expand control visible', + '- Files: before-chat.md opens as a Markdown preview', + '- Narrow viewport: reopened preview fills the viewport', + '- Terminal: writes a file in the selected workspace before any user message or turn', '', + `\`\`\`json\n${JSON.stringify(await paneSnapshot(page), null, 2)}\n\`\`\``, + ].join('\n'), MODE) + const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) await input.press('Enter') await settled await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await paneSnapshot(page)).toEqual(blankPanes) + await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor() + for (const title of ['before-chat.md', 'Files']) { + const tab = blankColumn.locator('[data-dockkit-tab]').filter({ hasText: title }) + await tab.hover() + await tab.locator('[data-dockkit-tab-close]').click() + } await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) @@ -356,6 +420,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await compareOrRefreshGolden(SIDEBAR_EXPECTED, checkpoints.join('\n\n'), MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md', 'sidebar.expected.md', 'blank-session.expected.md']) }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 00716afb9e..adfe0407f4 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/ui-conversation/README.md -README.md: 802ba3b8d853457e00cab3180c251ffec5291f19 -README.zh.md: 83199417289770640c9c038d84ee3e0fa6ac7866 +README.md: e07c2f66e55d0bfe55b869d5b395914670278be3 +README.zh.md: 81e5300e5bac3d6d54e83ead165598cb2c378a99 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 802ba3b8d8..e07c2f66e5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -46,6 +46,8 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. +A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites. + View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8319941728..81e5300e5b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -46,6 +46,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册 strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 +blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。 + View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 57cbdbc577..a57c5462d3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -46,10 +46,14 @@ border-bottom: 0.5px solid var(--dsw-alias-border-l3); } -/* Blank hero/settling: keep the strict Session header mounted without taking - column space; the root-owned scrollport and composer remain below it. */ -.headerHidden { - display: none; +/* Blank Sessions retain navigation controls without conversation title or tabs. */ +.headerBlank { + min-height: 0; + border-bottom: none; +} + +.headerBlank .headerCorner { + margin-left: auto; } /* macOS hiddenInset titlebar: the title row doubles as the window drag @@ -67,15 +71,6 @@ -webkit-app-region: no-drag; } -/* Blank state on macOS desktop: the chrome rows are gone but the leading seat - (the hidden sidebar's reopen control) stays on screen, so the header keeps - its layout without the rule. */ -:global([data-platform='darwin']) .headerHidden { - display: block; - min-height: 0; - border-bottom: none; -} - .titleRow { display: flex; align-items: center; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 92816a6e1c..8f025cf548 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,7 +1,6 @@ /** Strict per-session header/body content inserted into the resident conversation layout. */ import clsx from 'clsx' -import { isDarwinDesktop } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { @@ -55,8 +54,7 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum /** * Renders Session header chrome above the resident conversation scrollport. * @param props - Strict Session store, view ledger, navigation, render, and locale shares. - * @returns the header: title and tabs when visible, and on macOS desktop the - * always-mounted leading seat even while blank-session chrome hides. + * @returns Session navigation controls, with title and tabs after conversation starts. */ export function ConversationSessionHeader({ sessionId, useSession, useSessions, useConversation, useConversationViews, useStore, @@ -69,16 +67,8 @@ export function ConversationSessionHeader({ const session = useSession(s => s) const conversation = useConversation(s => s) const hideChrome = session.blank && conversationPhase(session, conversation) === 'blank' - // macOS desktop keeps the leading seat mounted through the blank state (the - // hidden sidebar's reopen control lives there), so the header may not leave - // the layout or the accessibility tree while its chrome hides. - const darwinDesktop = isDarwinDesktop() - return ( -
      +
      {renderSlot('conversation.session.header.leading', {})} @@ -142,11 +132,11 @@ export function ConversationSessionHeader({
      {renderSlot('conversation.session.header.utilities', {})}
      -
      - {renderSlot('conversation.session.header.corner', {})} -
      )} +
      + {renderSlot('conversation.session.header.corner', {})} +
      {!hideChrome && tabs.length > 1 && (
      diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 31d811a9ee..7d7ba8b366 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -462,7 +462,7 @@ describe('ConversationRoot resident composer', () => { expect(seat?.contains(fallback)).toBe(true) }) - it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { + it('hero phase: keeps sidebar controls accessible while hiding conversation chrome', () => { const b = mount( sessionSnapshotOf({ blank: true }), [ @@ -474,7 +474,11 @@ describe('ConversationRoot resident composer', () => { const host = b.view.container.querySelector('[data-conversation-scroll]') const header = b.view.container.querySelector('header') expect(host).not.toBeNull() - expect(header?.getAttribute('aria-hidden')).toBe('true') + expect(header?.getAttribute('aria-hidden')).not.toBe('true') + expect(b.view.getByTestId('view-conversation.session.header.corner')).toBeTruthy() + expect(b.view.queryByRole('tablist')).toBeNull() + expect(b.slotCalls).not.toContain('conversation.session.header.utilities') + expect(b.slotCalls).not.toContain('conversation.session.header.actions') expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() diff --git a/snapshots/web/details-session-lifecycle/blank-session.expected.md b/snapshots/web/details-session-lifecycle/blank-session.expected.md new file mode 100644 index 0000000000..7ac71fccc0 --- /dev/null +++ b/snapshots/web/details-session-lifecycle/blank-session.expected.md @@ -0,0 +1,25 @@ +# Blank Session workspace sidebar + +- No selected Session: expand control absent +- Selected workspace before first message: expand control visible +- Files: before-chat.md opens as a Markdown preview +- Narrow viewport: reopened preview fills the viewport +- Terminal: writes a file in the selected workspace before any user message or turn + +```json +[ + { + "active": true, + "tabs": [ + { + "title": "Files", + "selected": false + }, + { + "title": "before-chat.md", + "selected": true + } + ] + } +] +``` From b0542ec0e2e4d616d7e3c66cebe188723ef2f9d4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 17:48:48 +0800 Subject: [PATCH 31/37] test(web): update blank session header snapshots --- snapshots/web/lifecycle-chrome/hero.expected.md | 3 +++ snapshots/web/lifecycle-chrome/plan-active.expected.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index 2a7bfef3e7..586271521d 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -22,6 +22,9 @@ - button "Settings": - img - text: Settings +- banner: + - button "Open right sidebar": + - img - text: Into the Unknown Preview - button "Choose workspace": - img diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index 3c3cb7da3a..044f7ebb12 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -22,6 +22,9 @@ - button "Settings": - img - text: Settings +- banner: + - button "Open right sidebar": + - img - text: Into the Unknown Preview - button "Choose workspace": - img From 51a9382b610dad0398e8070a944ccabe8cb1e088 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 17:57:09 +0800 Subject: [PATCH 32/37] test(web): await terminal close acknowledgement before cleanup assertions --- apps/web/tests/details-session-lifecycle.e2e.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 06f05acdc2..3cb4a1f8b9 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -198,8 +198,13 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(sessionEvents).not.toContain('turn/start') expect(sessionEvents).not.toContain('user/message') await blankColumn.locator('[data-dockkit-tab][aria-selected="true"]').hover() - await blankColumn.locator('[data-dockkit-tab][aria-selected="true"] [data-dockkit-tab-close]').click() - await expect.poll(() => scaffold.ctx.terminalController.list(agent.id)).toEqual([]) + // The tab disappears before the Host finishes process cleanup; close responds after quiescence. + const [closed] = await Promise.all([ + page.waitForResponse('**/api/terminal/close'), + blankColumn.locator('[data-dockkit-tab][aria-selected="true"] [data-dockkit-tab-close]').click(), + ]) + expect((await closed.json()).result.ok).toBe(true) + expect(scaffold.ctx.terminalController.list(agent.id)).toEqual([]) await blankColumn.locator('[data-dockkit-tab]').filter({ hasText: 'before-chat.md' }).click() await compareOrRefreshGolden(BLANK_EXPECTED, [ '# Blank Session workspace sidebar', '', From d1e93e5482080d36d19a10dcf0f5f9fd1ad6f2eb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 18:03:03 +0800 Subject: [PATCH 33/37] test(web): match the terminal close response without unsafe JSON access --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 3cb4a1f8b9..6f6fb83f10 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -203,7 +203,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S page.waitForResponse('**/api/terminal/close'), blankColumn.locator('[data-dockkit-tab][aria-selected="true"] [data-dockkit-tab-close]').click(), ]) - expect((await closed.json()).result.ok).toBe(true) + expect(await closed.json()).toMatchObject({ result: { ok: true } }) expect(scaffold.ctx.terminalController.list(agent.id)).toEqual([]) await blankColumn.locator('[data-dockkit-tab]').filter({ hasText: 'before-chat.md' }).click() await compareOrRefreshGolden(BLANK_EXPECTED, [ From f8b1309fe55ad29d54451886049d0c541be94d5a Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 16 Sep 2026 18:56:39 +0800 Subject: [PATCH 34/37] fix(subprocess): hide Windows shell console windows at creation --- ...ws-subprocess-console-visibility.i18n.yaml | 6 ++++ ...6-windows-subprocess-console-visibility.md | 29 +++++++++++++++++++ ...indows-subprocess-console-visibility.zh.md | 29 +++++++++++++++++++ ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +-- ...08-windows-acl-restricted-token-sandbox.md | 2 +- ...windows-acl-restricted-token-sandbox.zh.md | 2 +- .../sandbox-windows-acl/tests/control.spec.ts | 29 ++++++++++++++++++- .../subprocess-local/README.i18n.yaml | 4 +-- .../subprocess/subprocess-local/README.md | 2 ++ .../subprocess/subprocess-local/README.zh.md | 2 ++ .../subprocess-local/src/windows-job.ts | 1 + .../tests/native-windows.spec.ts | 21 ++++++++++++++ .../tests/windows-job.spec.ts | 1 + .../subprocess/win32-process/README.i18n.yaml | 4 +-- packages/subprocess/win32-process/README.md | 2 ++ .../subprocess/win32-process/README.zh.md | 2 ++ packages/subprocess/win32-process/src/abi.ts | 4 +++ packages/subprocess/win32-process/src/ffi.ts | 1 + .../subprocess/win32-process/src/process.ts | 12 +++++--- .../tests/fixtures/console-state.ts | 7 +++++ .../tests/ordinary-process.spec.ts | 2 +- .../win32-process/tests/process.spec.ts | 3 +- .../win32-process/verify/abi-probe.cpp | 4 +++ 23 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md create mode 100644 packages/subprocess/win32-process/tests/fixtures/console-state.ts diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.i18n.yaml new file mode 100644 index 0000000000..b6d4fe5084 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.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/bug-fix/2026-09-16-windows-subprocess-console-visibility.md +2026-09-16-windows-subprocess-console-visibility.md: 281525e960b049d5871cde9f5da5c8f928654440 +2026-09-16-windows-subprocess-console-visibility.zh.md: 04abf7154b4b384ca521b9d232c7dfb398f5ed99 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.md b/.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.md new file mode 100644 index 0000000000..281525e960 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.md @@ -0,0 +1,29 @@ +# Agent Note: Hide Windows subprocess console windows at creation + +Status: implemented + +English | [中文](2026-09-16-windows-subprocess-console-visibility.zh.md) + +## Problem + +PTC runtime and shell calls share the Windows subprocess provider. Its ordinary Job runner omits window hiding, and native targets supply standard handles without a startup visibility flag. Desktop execution can therefore flash console windows for short-lived commands. + +## Decision + +The private Node Job runner uses `windowsHide: true`. Native ordinary and restricted-token process creation supplies `STARTF_USESHOWWINDOW` and `SW_HIDE` alongside standard handles before target code runs. Console inheritance, Job assignment before resume, and pipe ownership stay intact. No operation hides an existing parent console or promises to suppress windows explicitly opened by the command. + +The [ACL sandbox decision](../feature/2026-08-08-windows-acl-restricted-token-sandbox.md) still owns restricted-token policy and console-isolation limits. Initial window visibility does not require adding `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` to restricted creation. + +## Alternatives considered + +**Hide only the outer runner.** Native target creation is independent of Node's launch options, so its initial visibility also needs an explicit setting. + +**Remove consoles from every process.** Restricted-token creation with console-isolation flags has a recorded DLL initialization failure. Startup visibility preserves the existing console attachment rules instead. + +**Hide the window after PowerShell starts.** A window can become visible before the script executes; creation-time settings avoid that interval. + +## Consequences + +Ordinary subprocess startup suppresses incidental console windows without changing tool output or process cleanup. Native Windows tests inspect console visibility in a descendant and in both ACL modes, alongside existing stream, control-pipe, and Job-lifetime tests. A missing console is valid; the tests do not require one to exist. Startup-parameter tests pin the creation-time guarantee that a final visibility observation alone cannot establish. + +Session recordings cannot observe native console windows and their transcripts are unchanged. Windows native tests own this regression; browser screenshots cannot establish the absence of desktop windows. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md new file mode 100644 index 0000000000..04abf7154b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 在创建时隐藏 Windows 子进程控制台窗口 + +Status: implemented + +[English](2026-09-16-windows-subprocess-console-visibility.md) | 中文 + +## 问题 + +PTC 运行时和 shell 调用共用 Windows 子进程提供方。其普通 Job runner 未设置窗口隐藏,原生目标只提供标准句柄而没有启动可见性标志。因此,Desktop 执行短命令时可能闪现控制台窗口。 + +## 决策 + +私有 Node Job runner 使用 `windowsHide: true`。普通和受限令牌原生进程创建在目标代码运行前,将 `STARTF_USESHOWWINDOW` 和 `SW_HIDE` 与标准句柄一起传入。控制台继承、恢复线程前分配 Job 和管道归属保持不变。任何操作都不隐藏已有父控制台,也不承诺抑制命令显式打开的窗口。 + +[ACL 沙箱决策](../feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md) 仍负责受限令牌策略和控制台隔离限制。设置初始窗口可见性不需要向受限进程创建添加 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE`。 + +## 考虑过的替代方案 + +**仅隐藏外层 runner。** 原生目标创建独立于 Node 启动选项,因此也需要明确设置初始可见性。 + +**移除所有进程的控制台。** 受限令牌配合控制台隔离标志存在已记录的 DLL 初始化失败。启动可见性设置保留现有控制台附着规则。 + +**PowerShell 启动后再隐藏窗口。** 窗口可能在脚本执行前已经可见;创建时设置可避免这一间隔。 + +## 后果 + +普通子进程启动抑制附带控制台窗口,不改变工具输出或进程清理。原生 Windows 测试检查后代进程和两种 ACL 模式的控制台可见性,并配合现有流、控制管道和 Job 生命周期测试。没有控制台也是有效状态;测试不要求控制台必须存在。启动参数测试固定创建时的保证,单独观察最终可见性无法确认这一点。 + +会话录制无法观察原生控制台窗口,转录内容也没有变化。此回归由 Windows 原生测试负责;浏览器截图无法确认桌面窗口不存在。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 8e82a9d97a..596033c717 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.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-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: a70fd8839c371c11b6660229146306f7c5aa642a -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 892eabf17c63b7ceaa9bb8b21ac8c6e71a2f6112 +2026-08-08-windows-acl-restricted-token-sandbox.md: 9834e9b559f0a8045b0d36dd2dcfba864238996b +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: d349b504ca6a1054237d5ca6aa61547c2a0dcdad diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index a70fd8839c..9834e9b559 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -32,7 +32,7 @@ The landstrip evaluation was rejected before implementation (not battle-tested; ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 892eabf17c..d349b504ca 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -32,7 +32,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## 测试 diff --git a/packages/sandbox/sandbox-windows-acl/tests/control.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/control.spec.ts index 8e419b480c..d1a46e1192 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/control.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/control.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' import { SUBPROCESS_CONTROL_ENV } from '@deepseek-ai/dsh-subprocess/control' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -18,6 +18,33 @@ afterEach(async () => { }) describe.skipIf(process.platform !== 'win32')('managed Windows ACL control pipe', () => { + it.each(['read-only', 'workspace-write'])('runs without a visible console in %s mode', async (mode) => { + scratch = await mkdtemp(join(tmpdir(), 'dsh-acl-console-')) + const workspace = join(scratch, 'workspace') + const temp = join(scratch, 'temp') + await mkdir(workspace) + await mkdir(temp) + ctx = new Context() + await ctx.plugin(LocalSubprocessRuntime) + const runner = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) + const fixture = fileURLToPath(new URL('../../../subprocess/win32-process/tests/fixtures/console-state.ts', import.meta.url)) + const handle = ctx.subprocess.spawn({ + argv: [process.execPath, '--import', 'tsx/esm', runner, + '--workspace', workspace, '--temp', temp, '--mode', mode, '--', process.execPath, fixture], + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 4096 } }, + graceMs: 1000, + }) + try { + expect(await handle.done).toEqual({ exitCode: 0, signal: null }) + expect(handle.collected.stderr?.readFrom(0).text).toBe('') + expect(JSON.parse(handle.collected.stdout?.readFrom(0).text ?? '')).toMatchObject({ visible: false }) + } finally { + handle.terminate() + await handle.waitForExit() + } + }) + it('preserves binary bytes through the Job and restricted-token runners while denying writes', async () => { scratch = await mkdtemp(join(tmpdir(), 'dsh-acl-control-')) ctx = new Context() diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index ce5964372c..d4858f0dad 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md -README.md: 224634dc5493dba89bccc864fb8e2d2cd1ea9fe1 -README.zh.md: 439aedeac3ccaa4331a1c7e1a2c4b7092bce8384 +README.md: 24480565de2f324a451dfdb40101eb50703b30b6 +README.zh.md: c86a7eb930834d960fa25dd7b5d175af63d0b31a diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 224634dc54..24480565de 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -40,6 +40,8 @@ Load the provider in the same composition as its consumers. It has no config fie Absolute executable paths are verified; bare names resolve against the scrubbed PATH with platform-aware executable extensions (`.COM`/`.EXE`/`.BAT`/`.CMD` on Windows). Relative paths containing separators are rejected — provide an absolute path or a bare PATH name — and relative PATH entries resolve from the host process cwd. +Windows ordinary subprocesses start the private Job runner with `windowsHide` and request hidden initial windows for native targets. Standard streams and Job ownership remain independent of window visibility; commands that explicitly create their own windows are outside this guarantee. + ### Collecting output Collect mode keeps the last `maxBytes` of a stream in memory — errors and final results cluster at the end — and, when a `spill` cap is configured, appends the complete stream to a private file under a per-process directory in the OS temp dir (a `0700` directory, `0600` random-named files). A stream larger than the spill cap discards its incomplete spill and returns only the marked truncated tail. Reads are offset-based and non-consuming, so background and batch readers coexist before and after exit. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 439aedeac3..c86a7eb930 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -40,6 +40,8 @@ kind: "package-reference" 绝对可执行文件路径会被验证;裸名称根据清理后的 PATH 并以平台感知的可执行文件扩展名(Windows 上为 `.COM`/`.EXE`/`.BAT`/`.CMD`)解析。含分隔符的相对路径会被拒绝——请提供绝对路径或裸 PATH 名称——相对 PATH 条目从宿主进程 cwd 解析。 +Windows 普通子进程通过 `windowsHide` 启动私有 Job runner,并为原生目标请求隐藏初始窗口。标准流和 Job 归属不依赖窗口可见性;显式创建自身窗口的命令不在此保证范围内。 + ### 收集输出 收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。 diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index e0b7b010bf..57f7c46afc 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -138,6 +138,7 @@ export function launchWindowsJob( ...spec.argv, ], { cwd: process.cwd(), + windowsHide: true, env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation), stdio: runnerStdio(spec, true, ignoredStdinFd ?? 'pipe'), }) as RunnerProcess diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 2d14ca026d..c426171a89 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process' import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { targetEnvironment } from '../src/runner-launch.ts' @@ -84,6 +85,26 @@ function directSpawnFailure(argv: readonly string[], cwd = scratch): Promise { + it('keeps ordinary descendants free of visible console windows', async () => { + const fixture = fileURLToPath(new URL('../../win32-process/tests/fixtures/console-state.ts', import.meta.url)) + const script = ` + const { spawnSync } = require('node:child_process') + const child = spawnSync(process.execPath, [process.argv[1]], { stdio: 'inherit' }) + if (child.error) throw child.error + process.exitCode = child.status ?? 1 + ` + const request = spec([process.execPath, '-e', script, fixture]) + const handle = bindManagedProcess(request, launchWindowsJob(request, targetEnvironment(request))) + try { + expect(await handle.done).toEqual({ exitCode: 0, signal: null }) + expect(handle.collected.stderr?.readFrom(0).text).toBe('') + expect(JSON.parse(handle.collected.stdout?.readFrom(0).text ?? '')).toMatchObject({ visible: false }) + } finally { + handle.terminate() + await handle.waitForExit() + } + }) + it('keeps raw stdin writable while the runner starts the target', async () => { const output = join(scratch, `stdin-${Date.now()}.txt`) const script = ` diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 6b0eacb6ca..c2f30a682b 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -182,6 +182,7 @@ describe('Windows parent runner contract', () => { 'C:\\runner.js', '--', 'tool.exe', 'literal arg', ], expect.objectContaining({ cwd: process.cwd(), + windowsHide: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2], })) expect(child.sent).toEqual([{ type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes' } }]) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 5d1efcc3a8..77ef70c41d 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/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/subprocess/win32-process/README.md -README.md: e3009bb207e0436b4d4113d7bf1ea677ab191abc -README.zh.md: 65f834b447a2d9f4db94130fe3f7070456aab351 +README.md: 155ca3807791a83fef437af67312b3480ce7d2a2 +README.zh.md: f0c4439af27ef45c88d73bcba54851d789da063d diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index e3009bb207..155ca38077 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -32,6 +32,8 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and - **Ordinary settlement operations** — `pollProcessExit()` publishes direct exit separately, while `isJobEmpty()` reads `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. Checked Job termination and handle closure keep the runner as the only native owner. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. +Process creation sets `STARTF_USESHOWWINDOW` with `SW_HIDE` before target code runs. It preserves console inheritance and does not add `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE`, which can fail DLL initialization under the restricted token. Existing parent console windows are not hidden. + The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. - **Inherited control descriptor** — Job creation accepts an optional fd-7 pipe. `STARTUPINFO.cbReserved2/lpReserved2` carries an eight-slot CRT descriptor table with standard handles, closed slots 3–6, and the control pipe at slot 7. The table is allocated until CreateProcess returns; temporary handle inheritance is reset on success and failure. Initializing the slot before Node starts avoids overwriting descriptors Node has already allocated. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 65f834b447..f0c4439af2 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -32,6 +32,8 @@ kind: "package-library" - **ordinary 结算操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与句柄关闭使 runner 保持唯一 native owner。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭沙箱 process 句柄;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回句柄。 +进程创建在目标代码运行前设置 `STARTF_USESHOWWINDOW` 和 `SW_HIDE`。它保留控制台继承,不添加可能导致受限令牌下 DLL 初始化失败的 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE`。已有的父进程控制台窗口不会被隐藏。 + Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child 策略。 - **继承控制描述符**——Job 创建接受可选的 fd-7 管道。`STARTUPINFO.cbReserved2/lpReserved2` 携带八槽 CRT 描述符表,其中包含标准句柄、关闭的槽 3–6,以及槽 7 的控制管道。该表保留到 CreateProcess 返回;临时句柄继承在成功和失败时均恢复。在 Node 启动前初始化该槽可避免覆盖 Node 已分配的描述符。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index e34d941613..7fd10f7b3d 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -2,6 +2,10 @@ /** STARTUPINFOW uses the standard input, output, and error handles. */ export const STARTF_USESTDHANDLES = 0x00000100 +/** STARTUPINFOW applies wShowWindow when creating a console window. */ +export const STARTF_USESHOWWINDOW = 0x00000001 +/** Initial window visibility that preserves the child's console attachment. */ +export const SW_HIDE = 0 /** HandleInformation flag that permits child inheritance. */ export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 2b5fb47c70..b785016023 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -38,6 +38,7 @@ export function isNullPtr(value: NativePtr | null | undefined): value is null | export interface StartupInfoInput { cb: number dwFlags: number + wShowWindow: number hStdInput: NativePtr hStdOutput: NativePtr hStdError: NativePtr diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 12354a104b..34ced076c1 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -201,6 +201,7 @@ function createRestrictedProcess( /** * Spawn a process with anonymous-pipe stdout/stderr and immediate stdin EOF. + * New console windows start hidden without changing console inheritance. * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. * @returns caller-owned process and pipe read handles. @@ -228,7 +229,8 @@ export function spawnPipedProcess( startupInfo = allocStartupInfo() encodeStartupInfo(startupInfo, { cb: abi.STARTUPINFOW_SIZE, - dwFlags: abi.STARTF_USESTDHANDLES, + dwFlags: abi.STARTF_USESTDHANDLES | abi.STARTF_USESHOWWINDOW, + wShowWindow: abi.SW_HIDE, hStdInput: stdIn.read, hStdOutput: stdOut.write, hStdError: stdErr.write, @@ -449,7 +451,9 @@ function spawnJobProcess( startupInfo = allocStartupInfo() encodeStartupInfo(startupInfo, { cb: abi.STARTUPINFOW_SIZE, - dwFlags: abi.STARTF_USESTDHANDLES, + // Preserve console inheritance: CREATE_NO_WINDOW can fail restricted-token DLL initialization. + dwFlags: abi.STARTF_USESTDHANDLES | abi.STARTF_USESHOWWINDOW, + wShowWindow: abi.SW_HIDE, hStdInput: stdio.stdin, hStdOutput: stdio.stdout, hStdError: stdio.stderr, @@ -516,7 +520,7 @@ function spawnJobProcess( } /** - * Spawn a restricted-token process suspended, assign its Job, then resume it. + * Spawn a restricted-token process suspended with hidden initial windows, assign its Job, then resume it. * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. * @returns caller-owned process and Job handles after successful resume. @@ -542,7 +546,7 @@ export function spawnInheritedJobProcess( } /** - * Spawn an ordinary process suspended, assign its Job, then resume it. + * Spawn an ordinary process suspended with hidden initial windows, assign its Job, then resume it. * @param api - active binding table. * @param options - command, cwd, argv, and target carrier descriptors. * @returns caller-owned process and Job handles after successful resume. diff --git a/packages/subprocess/win32-process/tests/fixtures/console-state.ts b/packages/subprocess/win32-process/tests/fixtures/console-state.ts new file mode 100644 index 0000000000..4ae43ba4b7 --- /dev/null +++ b/packages/subprocess/win32-process/tests/fixtures/console-state.ts @@ -0,0 +1,7 @@ +/** Reports the inherited console's visibility from a real Windows child. */ +import koffi from 'koffi' + +const getConsoleWindow = koffi.load('kernel32.dll').func('void * __stdcall GetConsoleWindow()') +const isWindowVisible = koffi.load('user32.dll').func('int __stdcall IsWindowVisible(void *)') +const window = getConsoleWindow() as bigint | null +process.stdout.write(JSON.stringify({ attached: window !== null, visible: window !== null && isWindowVisible(window) !== 0 })) diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index d43c6ae962..e1538148a0 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -201,7 +201,7 @@ describe('ordinary Job process operations', () => { }), }) expect(spawnCurrentTokenJobProcess(bindings, options())).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(startup).toMatchObject({ hStdInput: 104n, hStdOutput: 105n, hStdError: 106n }) + expect(startup).toMatchObject({ dwFlags: 0x101, wShowWindow: 0, hStdInput: 104n, hStdOutput: 105n, hStdError: 106n }) expect(uvGetOsfhandle).toHaveBeenNthCalledWith(1, 4) expect(uvGetOsfhandle).toHaveBeenNthCalledWith(2, 5) expect(uvGetOsfhandle).toHaveBeenNthCalledWith(3, 6) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index d82fe7cfe1..fc96ef9df8 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -7,7 +7,7 @@ import { spawnPipedProcess, } from '../src/index.ts' import { CREATE_SUSPENDED } from '../src/abi.ts' -import { processInformationType } from '../src/ffi.ts' +import { processInformationType, startupInfoType } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' const PVOID = koffi.pointer('void') @@ -23,6 +23,7 @@ function inheritedApi(overrides: Partial = {}): { const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] = overrides.createProcessAsUserW ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + expect(koffi.decode(_startup, startupInfoType())).toMatchObject({ dwFlags: 0x101, wShowWindow: 0 }) events.push('create') koffi.encode(info, processInformationType(), { hProcess: 60n, diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index f57d707702..231a2fac76 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -20,6 +20,8 @@ int wmain() P(CREATE_SUSPENDED); P(CREATE_UNICODE_ENVIRONMENT); P(STARTF_USESTDHANDLES); + P(STARTF_USESHOWWINDOW); + P(SW_HIDE); P(HANDLE_FLAG_INHERIT); P(INFINITE); P(WAIT_TIMEOUT); @@ -45,6 +47,8 @@ int wmain() static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(CREATE_UNICODE_ENVIRONMENT == 0x400, "Unicode environment flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); + static_assert(STARTF_USESHOWWINDOW == 0x1, "initial window visibility flag"); + static_assert(SW_HIDE == 0, "hidden window value"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size"); From 974d0271c00698fdf3e6113a37f11f2e24c615ae Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 19:32:25 +0800 Subject: [PATCH 35/37] fix(deliverables): satisfy the static gates and Windows tests Regenerate the client and persistence catalogs, document the review address builder with a plain parameter, share the changed-file coordinate parsing between the comparison and native-open routes, and fold the summary and comparison stores onto one Host read store. A path with no existing ancestor but the filesystem root keeps its spelling on every platform, and the temp-root test redirects TMP and TEMP as well as TMPDIR while counting only the recorder's own directories. --- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 2 +- .../client/ui-deliverables/src/changes.ts | 3 +- .../src/client/changes-diff.ts | 70 +++++----------- .../src/client/changes-summary.ts | 65 ++++----------- .../src/client/host-read-store.ts | 81 +++++++++++++++++++ .../ui-deliverables/src/present-open.ts | 26 +++--- .../workspace-changes/src/paths.ts | 6 +- .../workspace-changes/tests/paths.spec.ts | 2 + .../workspace-changes/tests/plugin.spec.ts | 21 +++-- .../src/client/slot-catalog.ts | 1 + 13 files changed, 162 insertions(+), 127 deletions(-) create mode 100644 packages/client/ui-deliverables/src/client/host-read-store.ts diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 2540a8fbaf..a682a4eae2 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: 637dcfcf48d2699b0072d087c7a765fe900a7684 -persistence-catalog.zh.md: e8af53ae630ac7b061e9d158eef8f26d2a5fdde4 +persistence-catalog.md: 0957adb0ee15587eedff2b38742616bc4c8decba +persistence-catalog.zh.md: 45e7155b0ddf8d72ac132e0ac79ea31c28acea3c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 637dcfcf48..0957adb0ee 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1256,7 +1256,7 @@ Source: [`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/ 'workspace/changes': { turn: number } ``` -Source: [`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts) +Source: [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) ## Resolved persistence types @@ -3920,7 +3920,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts) +Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index e8af53ae63..45e7155b0d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1258,7 +1258,7 @@ export type SessionEvent = { 'workspace/changes': { turn: number } ``` -来源:[`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts) +来源:[`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) ## 已解析的持久化类型 @@ -3922,7 +3922,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts) +来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index c62914b099..42f29faa41 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -60698,7 +60698,7 @@ ], "sources": [ "packages/core/session/src/types.ts:276", - "packages/deliverables/workspace-changes/src/types.ts:58" + "packages/deliverables/workspace-changes/src/types.ts:106" ] }, { diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts index 29b0da9faf..1e4ec7dddd 100644 --- a/packages/client/ui-deliverables/src/changes.ts +++ b/packages/client/ui-deliverables/src/changes.ts @@ -128,7 +128,8 @@ export function changedFileUrl(sessionId: SessionId, seq: number, index: number) * @param coordinates - viewed Session, announcing event, and turn. * @returns a `dsh-resource://changes-review/session/…` address. */ -export function changesReviewAddress({ sessionId, seq, turn }: ChangesReviewCoordinates): string { +export function changesReviewAddress(coordinates: ChangesReviewCoordinates): string { + const { sessionId, seq, turn } = coordinates return `${CHANGES_REVIEW_ADDRESS}${encodeURIComponent(sessionId)}/${seq}/${turn}` } diff --git a/packages/client/ui-deliverables/src/client/changes-diff.ts b/packages/client/ui-deliverables/src/client/changes-diff.ts index c9fb23b0fd..2a22bf6d02 100644 --- a/packages/client/ui-deliverables/src/client/changes-diff.ts +++ b/packages/client/ui-deliverables/src/client/changes-diff.ts @@ -1,7 +1,7 @@ -/** Cache of the file comparisons the Host serves for listed changed files, read once per comparison and again on request. */ -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +/** Cache of the file comparisons the Host serves for listed changed files, read once per comparison and again after a failure. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { changesDiffUrl, isChangesDiff, type ChangesDiff } from '../changes.ts' +import { HostReadStore } from './host-read-store.ts' /** * A comparison, `'missing'` once the Host no longer serves it, `'error'` for @@ -9,14 +9,21 @@ import { changesDiffUrl, isChangesDiff, type ChangesDiff } from '../changes.ts' */ export type ChangesDiffState = ChangesDiff | 'missing' | 'error' | 'loading' -/** One browser plugin's comparison reads, cleared on connection replacement and cancelled on disposal. */ -export class ChangesDiffStore { - /** Comparison URLs key the state across Sessions, turns, and files. */ - readonly state = createSnapshotStore>({}) - private readonly lifetime = new AbortController() - /** The connection generation the current states belong to; a reset aborts it so no older read publishes. */ - private generation = new AbortController() - private readonly pending = new Set>() +/** One browser plugin's comparison reads; a failed read is the one state a later request replaces. */ +export class ChangesDiffStore extends HostReadStore { + constructor() { + super({ + loading: 'loading', + failed: 'error', + retryable: state => state === 'error', + decode: async (response) => { + if (response.status === 404) return 'missing' + if (!response.ok) return 'error' + const value: unknown = await response.json() + return isChangesDiff(value) ? value : 'error' + }, + }) + } /** * Read one comparison; a cached comparison or a missing one is kept, a failed one is read again. @@ -25,46 +32,7 @@ export class ChangesDiffStore { * @param index - the file's index in the summary. * @returns after the state is published. */ - async load(sessionId: SessionId, seq: number, index: number): Promise { - const url = changesDiffUrl(sessionId, seq, index) - const current = this.state.getSnapshot()[url] - if (this.lifetime.signal.aborted || (current !== undefined && current !== 'error')) return - this.state.update((state) => { state[url] = 'loading' }) - const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal])) - this.pending.add(task) - try { - await task - } finally { - this.pending.delete(task) - } - } - - /** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */ - reset(): void { - this.generation.abort() - this.generation = new AbortController() - this.state.set({}) - } - - /** Cancel outstanding reads and wait until none can publish state. */ - async dispose(): Promise { - this.lifetime.abort() - await Promise.all(this.pending) - } - - private async read(url: string, signal: AbortSignal): Promise { - let next: ChangesDiffState = 'error' - try { - const response = await fetch(url, { signal }) - if (response.status === 404) next = 'missing' - else if (response.ok) { - const value: unknown = await response.json() - if (isChangesDiff(value)) next = value - } - } catch { - // A transport failure stays retryable until the connection is replaced and the store reset. - next = 'error' - } - if (!signal.aborted) this.state.update((state) => { state[url] = next }) + load(sessionId: SessionId, seq: number, index: number): Promise { + return this.loadUrl(changesDiffUrl(sessionId, seq, index)) } } diff --git a/packages/client/ui-deliverables/src/client/changes-summary.ts b/packages/client/ui-deliverables/src/client/changes-summary.ts index 5dd4b37be8..49d728f67f 100644 --- a/packages/client/ui-deliverables/src/client/changes-summary.ts +++ b/packages/client/ui-deliverables/src/client/changes-summary.ts @@ -1,19 +1,25 @@ /** Fetch-once cache of the change summaries the Host serves for announced `workspace/changes` events. */ -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { changesSummaryUrl, isChangesSummary, type ChangesSummary } from '../changes.ts' +import { HostReadStore } from './host-read-store.ts' /** A summary, `'missing'` once the Host no longer serves it, or `'loading'` while the request runs. */ export type ChangesSummaryState = ChangesSummary | 'missing' | 'loading' -/** One browser plugin's summary reads, cleared on connection replacement and cancelled on disposal. */ -export class ChangesSummaryStore { - /** Summary URLs key the state across Sessions and turns. */ - readonly state = createSnapshotStore>({}) - private readonly lifetime = new AbortController() - /** The connection generation the current states belong to; a reset aborts it so no older read publishes. */ - private generation = new AbortController() - private readonly pending = new Set>() +/** One browser plugin's summary reads; a summary or a missing answer is kept until the connection is replaced. */ +export class ChangesSummaryStore extends HostReadStore { + constructor() { + super({ + loading: 'loading', + failed: 'missing', + retryable: () => false, + decode: async (response) => { + if (!response.ok) return 'missing' + const value: unknown = await response.json() + return isChangesSummary(value) ? value : 'missing' + }, + }) + } /** * Read one summary once; a later read of the same coordinates returns the cached state. @@ -21,44 +27,7 @@ export class ChangesSummaryStore { * @param seq - the announcing event's sequence. * @returns after the state is published. */ - async load(sessionId: SessionId, seq: number): Promise { - const url = changesSummaryUrl(sessionId, seq) - if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] !== undefined) return - this.state.update((state) => { state[url] = 'loading' }) - const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal])) - this.pending.add(task) - try { - await task - } finally { - this.pending.delete(task) - } - } - - /** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */ - reset(): void { - this.generation.abort() - this.generation = new AbortController() - this.state.set({}) - } - - /** Cancel outstanding reads and wait until none can publish state. */ - async dispose(): Promise { - this.lifetime.abort() - await Promise.all(this.pending) - } - - private async read(url: string, signal: AbortSignal): Promise { - let next: ChangesSummaryState = 'missing' - try { - const response = await fetch(url, { signal }) - if (response.ok) { - const value: unknown = await response.json() - if (isChangesSummary(value)) next = value - } - } catch { - // A transport failure reads as missing until the connection is replaced and the store reset. - next = 'missing' - } - if (!signal.aborted) this.state.update((state) => { state[url] = next }) + load(sessionId: SessionId, seq: number): Promise { + return this.loadUrl(changesSummaryUrl(sessionId, seq)) } } diff --git a/packages/client/ui-deliverables/src/client/host-read-store.ts b/packages/client/ui-deliverables/src/client/host-read-store.ts new file mode 100644 index 0000000000..120504ee2e --- /dev/null +++ b/packages/client/ui-deliverables/src/client/host-read-store.ts @@ -0,0 +1,81 @@ +/** + * Fetch-once cache of Host-served records keyed by their authenticated URL: + * one read per URL while a state stands, cleared on connection replacement, + * cancelled on disposal. Each store decides what a response means and which + * states a later request reads again. + */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' + +/** What one store makes of its reads. */ +export interface HostReadPolicy { + /** The state published while a request runs. */ + loading: T + /** The state a transport failure publishes. */ + failed: T + /** + * Whether a later request replaces a standing state with a new read. + * @param state - the standing state. + * @returns true to read again. + */ + retryable(state: T): boolean + /** + * Turn a settled response into a state. + * @param response - the Host's answer. + * @returns the state to publish. + */ + decode(response: Response): Promise +} + +/** One browser plugin's reads of one record kind. */ +export class HostReadStore { + /** Record URLs key the state across Sessions and turns. */ + readonly state = createSnapshotStore>({}) + private readonly lifetime = new AbortController() + /** The connection generation the current states belong to; a reset aborts it so no older read publishes. */ + private generation = new AbortController() + private readonly pending = new Set>() + + constructor(private readonly policy: HostReadPolicy) {} + + /** + * Read one URL unless a state the policy keeps already stands for it. + * @param url - the record's authenticated URL. + * @returns after the state is published. + */ + protected async loadUrl(url: string): Promise { + const current = this.state.getSnapshot()[url] + if (this.lifetime.signal.aborted || (current !== undefined && !this.policy.retryable(current))) return + this.state.update((state) => { state[url] = this.policy.loading }) + const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal])) + this.pending.add(task) + try { + await task + } finally { + this.pending.delete(task) + } + } + + /** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */ + reset(): void { + this.generation.abort() + this.generation = new AbortController() + this.state.set({}) + } + + /** Cancel outstanding reads and wait until none can publish state. */ + async dispose(): Promise { + this.lifetime.abort() + await Promise.all(this.pending) + } + + private async read(url: string, signal: AbortSignal): Promise { + let next: T + try { + next = await this.policy.decode(await fetch(url, { signal })) + } catch { + // A transport failure publishes the policy's failed state until the connection is replaced and the store reset. + next = this.policy.failed + } + if (!signal.aborted) this.state.update((state) => { state[url] = next }) + } +} diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 233f58538e..56b88f0651 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -133,15 +133,23 @@ function handleChangesSummary(ctx: Context, request: Request): Response { return Response.json({ turn, files, total, added, deleted } satisfies ChangesSummary, { headers: { 'cache-control': 'no-store' } }) } -/** One listed file's comparison; 404 once the Host no longer serves the summary or the index names no file. */ -async function handleChangesDiff(ctx: Context, request: Request): Promise { +/** A changed file's coordinates from a route query, or the 400 to answer with. */ +function changedFileCoordinates(request: Request): { id: SessionId; seq: number; index: number } | Response { const query = new URL(request.url).searchParams const id = query.get('sessionId') const seq = coordinate(query.get('seq')) const index = coordinate(query.get('index')) if (!id || seq === undefined || index === undefined) return new Response('Invalid changed file coordinates.', { status: 400 }) + return { id: id as SessionId, seq, index } +} + +/** One listed file's comparison; 404 once the Host no longer serves the summary or the index names no file. */ +async function handleChangesDiff(ctx: Context, request: Request): Promise { + const coordinates = changedFileCoordinates(request) + if (coordinates instanceof Response) return coordinates + const { id, seq, index } = coordinates try { - const diff = await ctx.workspaceChanges.diff(id as SessionId, seq, index, request.signal) + const diff = await ctx.workspaceChanges.diff(id, seq, index, request.signal) if (diff === undefined) return new Response('Change comparison unavailable.', { status: 404 }) return Response.json(diff, { headers: { 'cache-control': 'no-store' } }) } catch (error: unknown) { @@ -151,20 +159,18 @@ async function handleChangesDiff(ctx: Context, request: Request): Promise { - const query = new URL(request.url).searchParams - const id = query.get('sessionId') - const seq = coordinate(query.get('seq')) - const index = coordinate(query.get('index')) - if (!id || seq === undefined || index === undefined) return new Response('Invalid changed file coordinates.', { status: 400 }) + const coordinates = changedFileCoordinates(request) + if (coordinates instanceof Response) return coordinates + const { id, seq, index } = coordinates try { request.signal.throwIfAborted() if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 }) - const changes = ctx.workspaceChanges.summary(id as SessionId, seq) + const changes = ctx.workspaceChanges.summary(id, seq) if (changes === undefined) return new Response('Change summary unavailable.', { status: 404 }) const workspaceRoot = changes.cwd const file = changes.files[index] if (file === undefined) return new Response('Changed file not found in this summary.', { status: 404 }) - const { absolutePath: path } = await ctx.workspaceFiles.stat({ sessionId: id as SessionId, workspaceRoot }, file.path, request.signal) + const { absolutePath: path } = await ctx.workspaceFiles.stat({ sessionId: id, workspaceRoot }, file.path, request.signal) return await openVerified(ctx, request, path, 'open') } catch (error: unknown) { request.signal.throwIfAborted() diff --git a/packages/deliverables/workspace-changes/src/paths.ts b/packages/deliverables/workspace-changes/src/paths.ts index 25cc47ed70..306f5bbe61 100644 --- a/packages/deliverables/workspace-changes/src/paths.ts +++ b/packages/deliverables/workspace-changes/src/paths.ts @@ -53,10 +53,10 @@ export async function canonicalPath(path: string): Promise { try { return join(await realpath(head), ...missing) } catch { - // A missing or unreadable component is kept lexically under its nearest resolvable ancestor. + // A missing or unreadable component is kept lexically under its nearest resolvable ancestor; + // a path with no existing ancestor but the root keeps its spelling entirely. const parent = dirname(head) - /* v8 ignore next -- the filesystem root always resolves, so the walk ends before reaching it. */ - if (parent === head) return path + if (parent === head || dirname(parent) === parent) return path missing.unshift(basename(head)) head = parent } diff --git a/packages/deliverables/workspace-changes/tests/paths.spec.ts b/packages/deliverables/workspace-changes/tests/paths.spec.ts index 699d8b90dd..8d0beee797 100644 --- a/packages/deliverables/workspace-changes/tests/paths.spec.ts +++ b/packages/deliverables/workspace-changes/tests/paths.spec.ts @@ -55,7 +55,9 @@ describe('canonicalPath', () => { expect(await canonicalPath(join(root, 'link', 'nested'))).toBe(join(resolvedReal, 'nested')) expect(await canonicalPath(join(root, 'link', 'nested', 'new.txt'))).toBe(join(resolvedReal, 'nested', 'new.txt')) expect(await canonicalPath(join(root, 'link', 'missing', 'deeper', 'new.txt'))).toBe(join(resolvedReal, 'missing', 'deeper', 'new.txt')) + // Nothing but the root exists above this path, so its spelling is kept as given on every platform. expect(await canonicalPath('/definitely/missing/root/file')).toBe('/definitely/missing/root/file') + expect(await canonicalPath(join(root, 'link', 'a'))).toBe(join(resolvedReal, 'a')) }) }) diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index 35c345e32e..b304c5a12a 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/packages/deliverables/workspace-changes/tests/plugin.spec.ts @@ -374,9 +374,16 @@ describe('workspace-changes without a repository', () => { const cwd = await scratchDir('dsh-workspace-changes-bounds-', cleanups) // The recorder places its temporary directory under the platform temp root; point that root at a scratch directory. const tempRoot = await scratchDir('dsh-workspace-changes-temp-', cleanups) - const previousTmp = process.env.TMPDIR - process.env.TMPDIR = tempRoot - cleanups.push(async () => { if (previousTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = previousTmp }) + // POSIX reads TMPDIR, Windows reads TMP then TEMP. + const previous = { TMPDIR: process.env.TMPDIR, TMP: process.env.TMP, TEMP: process.env.TEMP } + for (const name of ['TMPDIR', 'TMP', 'TEMP'] as const) process.env[name] = tempRoot + cleanups.push(async () => { + for (const name of ['TMPDIR', 'TMP', 'TEMP'] as const) { + if (previous[name] === undefined) Reflect.deleteProperty(process.env, name); else process.env[name] = previous[name] + } + }) + // Other tooling may create its own directories under the redirected temp root; only the recorder's count. + const recorderDirs = async (): Promise => (await readdir(tempRoot)).filter(name => name.startsWith('dsh-workspace-changes-')) await writeFile(join(cwd, 'grows.txt'), 'small\n') await writeFile(join(cwd, 'huge.txt'), 'a'.repeat(20)) await writeFile(join(cwd, 'mixed.dat'), Uint8Array.of(65, 0, 66)) @@ -412,12 +419,12 @@ describe('workspace-changes without a repository', () => { } expect(await ctx.workspaceChanges.diff(session.id, seq, 4, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+x'] }] }) // Every copy lives under the Session's temporary directory and goes with it. - const [scratch, ...others] = await readdir(tempRoot) + const [scratch, ...others] = await recorderDirs() expect(others).toEqual([]) - expect(scratch).toMatch(/^dsh-workspace-changes-/) - expect((await readdir(join(tempRoot, scratch!, 'captures'))).length).toBeGreaterThan(0) + expect(scratch).toBeDefined() + expect((await readdir(join(tempRoot, scratch as string, 'captures'))).length).toBeGreaterThan(0) ctx.emit('session/disposed', session) - await vi.waitFor(async () => { expect(await readdir(tempRoot)).toEqual([]) }) + await vi.waitFor(async () => { expect(await recorderDirs()).toEqual([]) }) }) it('drops a disposed session’s recorder and starts afresh on its next turn', async () => { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e080b567cd..8752a7de89 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2343,6 +2343,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: 'SidebarRightTabInjected', declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [ + 'client-ui-deliverables ReviewTab', 'client-ui-sidebar-documentpreview TextPreview', 'client-ui-sidebar-files FilesBody', 'client-ui-sidebar-right GuideBody', From c1f9d19658d356cafe112bc563de1c1b427c2ef0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 19:34:27 +0800 Subject: [PATCH 36/37] test(web): refresh the present-svg golden for the review-opening card --- snapshots/web/present-svg/ui.expected.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index e40d5ed75a..7ad3d0f167 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -49,10 +49,10 @@ - strong: 总线连接 - text: :CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向 - paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。 -- text: 已编辑 1 个文件 +83 -0 +- button "在侧边栏查看本轮改动": 已编辑 1 个文件 +83 -0 - list: - listitem: - - button "在侧边栏打开 von-neumann.svg": von-neumann.svg +83 -0 + - button "查看 von-neumann.svg 的改动": von-neumann.svg +83 -0 - text: 此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览 - button "在侧边栏预览 von-neumann.svg" - text: von-neumann.svg 冯诺依曼架构示意图 SVG From bab800bf74eba66de7df803bd22de2690256addf Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 19:57:41 +0800 Subject: [PATCH 37/37] docs(office-to-pdf): link the bounded conversion decision --- ...-09-15-bounded-office-conversion.i18n.yaml | 6 +++ .../2026-09-15-bounded-office-conversion.md | 37 +++++++++++++++++++ ...2026-09-15-bounded-office-conversion.zh.md | 37 +++++++++++++++++++ .../document/office-to-pdf/README.i18n.yaml | 4 +- packages/document/office-to-pdf/README.md | 2 + packages/document/office-to-pdf/README.zh.md | 2 + 6 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml new file mode 100644 index 0000000000..5b2a534550 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.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/architecture/2026-09-15-bounded-office-conversion.md +2026-09-15-bounded-office-conversion.md: 6a86fc8d24185d8f4d327e0407f21d55dc860a53 +2026-09-15-bounded-office-conversion.zh.md: d267c9ccb2b388c87c3823a9c998074ff9f01df8 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md new file mode 100644 index 0000000000..6a86fc8d24 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md @@ -0,0 +1,37 @@ +# Agent Note: Bounded shared Office conversion + +Status: implemented + +English | [中文](2026-09-15-bounded-office-conversion.zh.md) + +## Problem + +Office preview and explicit document inspection can request the same conversion. A completed-result cache alone leaves source reads, queued payloads, and concurrent readers unbounded. Speculation can also occupy capacity needed by a user, and canceling one consumer must not destroy another consumer's conversion. + +## Decision + +The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. + +The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse. + +A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles. + +Foreground preview and explicit QA requests precede background work. Disabling background conversions rejects speculative readers before they can join queued or running jobs, while completed alias hits remain available. Removing a queued foreground blocker immediately admits eligible background work. Queued priority follows live readers: a foreground join promotes a prewarm, and the final foreground cancellation demotes it and admits eligible work. Full queues evict queued speculation for foreground admission. Background concurrency reserves a foreground slot when total concurrency permits it. A speculative admission remains occupied through settlement, so promotion cannot admit additional speculation into capacity reserved for user work. Shared readers cancel independently, including readers joined after content hashing. The cache owns private output bytes and returns a copy to each caller. + +## Alternatives considered + +**Cache only completed PDFs.** This cannot bound pending source buffers, engine work, or response fanout, and separate consumers still duplicate conversion. + +**Use source metadata as the final cache identity.** Metadata is useful before reading, but distinct authorized paths can contain identical bytes. Content hashing provides cross-path reuse without treating a path as document content. + +**Cancel the entire conversion when one reader leaves.** An open preview can share work with speculation or explicit QA. Only the final reader owns cancellation of shared work. + +**Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work. + +**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. + +## Consequences + +The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. + +Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md new file mode 100644 index 0000000000..d267c9ccb2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 有界的共享 Office 转换 + +Status: implemented + +[English](2026-09-15-bounded-office-conversion.md) | 中文 + +## 问题 + +Office 预览和显式文档检查可能请求相同转换。仅缓存已完成结果无法限制源读取、排队载荷和并发读取方。推测工作也可能占用用户需要的容量,而取消一个消费者不能破坏另一个消费者的转换。 + +## 决策 + +`office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 + +[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 + +有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 + +前台预览和显式 QA 请求优先于后台工作。禁用后台转换时,推测读取方在加入排队或运行任务前即被拒绝,但仍可命中已完成的别名缓存。移除排队的前台阻塞任务后,符合条件的后台工作立即准入。排队优先级取决于活跃读取方:前台加入会提升预热,最后一个前台读取方取消后则恢复后台优先级,并准入符合条件的工作。队列满时为前台准入移除排队推测工作。总并发允许时,后台并发为前台预留一个槽位。推测工作的准入名额保留到工作结束,因此提权不会把为用户预留的容量用于更多推测工作。共享读取方独立取消,包括内容哈希后加入的读取方。缓存拥有私有输出字节,并为各调用方返回副本。 + +## 考虑过的替代方案 + +**仅缓存已完成 PDF。** 无法限制待完成源缓冲区、引擎工作和响应扇出,不同消费者仍会重复转换。 + +**以源元数据作为最终缓存身份。** 元数据在读取前有用,但不同的已授权路径可能包含相同字节。内容哈希提供跨路径复用,而不把路径当作文档内容。 + +**一个读取方离开就取消整个转换。** 打开的预览可能与推测工作或显式 QA 共享工作。只有最后一个读取方拥有共享工作取消权。 + +**另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。 + +**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 + +## 后果 + +缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 + +受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 diff --git a/packages/document/office-to-pdf/README.i18n.yaml b/packages/document/office-to-pdf/README.i18n.yaml index b0019ba621..9d9273a2ec 100644 --- a/packages/document/office-to-pdf/README.i18n.yaml +++ b/packages/document/office-to-pdf/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/document/office-to-pdf/README.md -README.md: 75f26612e649a4722543505cca9956541f07007b -README.zh.md: d9ecfb7347d22952b6100324926163649389773d +README.md: babfcaaa4136d58c586ab5c60e4a3a112d64b636 +README.zh.md: eb57fc6f42f6617886e154a176f40683f1e4e561 diff --git a/packages/document/office-to-pdf/README.md b/packages/document/office-to-pdf/README.md index 75f26612e6..babfcaaa41 100644 --- a/packages/document/office-to-pdf/README.md +++ b/packages/document/office-to-pdf/README.md @@ -42,6 +42,8 @@ The provider depends on the independently published [`@deepseek-ai/libreoffice-k The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-office-to-pdf) owns the full font, archive, and image settings. `fontDirectories` accepts absolute directories; omission uses the kit platform defaults. Explicit `fontFallbacks` replaces the kit's default groups. Installed requested fonts retain precedence, and other system fonts remain eligible for uncovered glyphs. Native engines can select installed metric-compatible fonts before these preferences. +The [bounded conversion decision](../../../.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md) explains queue admission, cache limits, and shared cancellation. + The provider retains successful PDFs by converter generation, Office extension, and SHA-256 of the exact source bytes. A bounded source-version index avoids rereading known content after an authorized stat; content identity also shares conversion across different source paths. Least-recently-used PDFs leave at either retention limit, together with their aliases. Failures and oversized cache entries are not retained. Every result has independent PDF/font buffers. Ready alias hits consume no reader slot; active source locators are released when their last reader leaves. Reopening a source after its final reader cancels rereads its bytes before sharing by digest, even if another source kept the conversion alive or its PDF is ready. Admission bounds queued metadata, outstanding readers, active source-byte reservations, and conversions before invoking a source read. Unknown source sizes reserve `maxInputBytes`; known sizes reserve their stat size. Reads receive that capacity and may read one overflow sentinel byte. `maxSourceBytes` must cover `maxInputBytes`. The final reader allowance is reserved for foreground work. Setting `maxBackgroundConversions` to zero rejects background joins to queued and running work; completed alias hits remain available. Background jobs wait while any foreground job is queued, including when it awaits source capacity. Foreground joins promote queued prewarming; when its last foreground reader leaves, the queued job returns to background priority and eligible work can start immediately. Foreground admission can evict queued speculation. Background concurrency leaves a foreground slot when total concurrency exceeds one. A running prewarm keeps its background admission slot until settlement, even after promotion. The final reader cancels shared work. Removing a queued foreground blocker immediately admits other eligible work; active reservations remain held until actual read/conversion cleanup settles. diff --git a/packages/document/office-to-pdf/README.zh.md b/packages/document/office-to-pdf/README.zh.md index d9ecfb7347..eb57fc6f42 100644 --- a/packages/document/office-to-pdf/README.zh.md +++ b/packages/document/office-to-pdf/README.zh.md @@ -42,6 +42,8 @@ kind: "package-reference" [配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-office-to-pdf)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 +[有界转换决策](../../../.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md)说明队列准入、缓存限额与共享取消的设计依据。 + 提供方按转换 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。