From b09d3a2c0d78221e33d70336cf0dc99ddc21fc88 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 3 Sep 2026 13:34:18 +0800 Subject: [PATCH 01/12] fix(ui-workspace): reveal opened search result --- ...-03-session-search-result-reveal.i18n.yaml | 6 ++ ...2026-09-03-session-search-result-reveal.md | 33 +++++++++++ ...6-09-03-session-search-result-reveal.zh.md | 33 +++++++++++ .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 4 +- .../2026-07-27-web-session-search.zh.md | 4 +- ...kspace-sidebar-order-and-folding.i18n.yaml | 4 +- ...-11-workspace-sidebar-order-and-folding.md | 6 +- ...-workspace-sidebar-order-and-folding.zh.md | 6 +- apps/web/tests/chat-long-interactions.e2e.ts | 1 - apps/web/tests/navigation-panes.e2e.ts | 10 ++-- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../ui-workspace/src/client/rows/Rows.tsx | 16 +++++- .../src/client/rows/WorkspaceBrowser.tsx | 44 ++++++++++++++- .../tests/workspace-browser.client.spec.tsx | 56 +++++++++++++++++-- 17 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml new file mode 100644 index 0000000000..c1419c8fff --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.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-03-session-search-result-reveal.md +2026-09-03-session-search-result-reveal.md: 9e07db5ffb1fc8ef011fa7b57730b7238709860b +2026-09-03-session-search-result-reveal.zh.md: 53af95eb49d764113ea4ef320df11ae826361ecd diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.md b/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.md new file mode 100644 index 0000000000..9e07db5ffb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.md @@ -0,0 +1,33 @@ +# Agent Note: Session search result reveal + +Status: implemented + +English | [中文](2026-09-03-session-search-result-reveal.zh.md) + +## Problem + +Selecting a Session search result opened its conversation while leaving the sidebar in the filtered search view. The user could not see where the Session belonged in the normal Workspace hierarchy. Clearing search alone was insufficient because the owning Workspace could be closed, the Session could be hidden beyond the five-row fold, and either grouped or flat navigation could place the row outside the scrollport. + +## Decision + +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) treats result selection as a transition back to normal browsing. It opens the owning Workspace or Ungrouped group, records the target Session id, clears the query, collapses search, and opens the Session. When grouped browsing remounts, the target group starts with its hidden remainder transiently revealed. Flat browsing needs no fold override. + +The normal Session row owns completion of the one-shot reveal. A matching mounted row scrolls itself into the nearest visible position and acknowledges the target id, preventing later renders from repeating the scroll. Metadata and content matches use the same transition because both result kinds resolve to a Session id. + +## Alternatives considered + +**Preserve the query after opening the Session.** This keeps the discovery context but leaves the user in the temporary result list and does not identify the Session's normal location. + +**Clear search without opening or unfolding the owning group.** The conversation would open while its selected row could remain hidden, reproducing the missing-location problem in a different sidebar state. + +**Persist an expanded-all preference for the group.** One navigation would permanently replace the bounded five-row presentation. The reveal instead expands the remainder only for the current tree mount. + +**Scroll from the browser parent.** The parent cannot complete the operation before a folded target row mounts. The row that owns the DOM element performs and acknowledges the scroll. + +## Consequences + +Selecting a result discards the current query and returns the sidebar to normal browsing. The owning group stays open, and its hidden remainder is visible for that tree mount, so the selected row supplies both hierarchy context and an on-screen location. A later search or ordinary render does not repeat the scroll after acknowledgment. + +## Testing + +UI tests cover a content-only hit in the sixth position of a closed Workspace, the corresponding transient group expansion and scroll acknowledgment, and the same one-shot scroll in flat mode. The assembled Web navigation test verifies that one click clears search and leaves exactly one selected Session row in the normal tree. The long-conversation browser test opens its seeded Session with that single-click transition. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.zh.md new file mode 100644 index 0000000000..53af95eb49 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Session 搜索结果显露 + +Status: implemented + +[English](2026-09-03-session-search-result-reveal.md) | 中文 + +## 问题 + +选择 Session 搜索结果会打开其对话,但侧边栏仍停留在筛选后的搜索视图。用户无法在常规 Workspace 层级中看到该 Session 的所属位置。仅清空搜索还不够,因为所属 Workspace 可能处于关闭状态,Session 可能隐藏在五行折叠之后,而分组或单列表导航都可能把该行放在滚动区域之外。 + +## 决策 + +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 将结果选择视为返回常规浏览的状态切换。它会打开所属 Workspace 或 Ungrouped 分组、记录目标 Session id、清空查询、收起搜索并打开 Session。分组浏览重新挂载时,目标分组会以临时显露其余隐藏条目的状态启动。单列表浏览不需要覆盖折叠状态。 + +常规 Session 行负责完成一次性显露。匹配的行挂载后会将自身滚动到最近的可见位置,再确认目标 id 已处理,避免后续渲染重复滚动。元数据命中与内容命中都会解析为 Session id,因此共用同一套状态切换。 + +## 考虑过的替代方案 + +**打开 Session 后保留查询。** 这会保留发现上下文,却仍让用户停留在临时结果列表中,无法识别 Session 在常规层级中的位置。 + +**只清空搜索,不打开或展开所属分组。** 对话会打开,但选中行仍可能隐藏,只是把缺失位置的问题带到另一种侧边栏状态。 + +**为分组持久保存全部展开偏好。** 一次导航会永久取代有界的五行呈现。本次显露只在当前树挂载期间展开其余条目。 + +**由浏览器父组件执行滚动。** 折叠的目标行挂载前,父组件无法完成操作。持有 DOM 元素的行负责滚动并确认完成。 + +## 后果 + +选择结果会丢弃当前查询,并让侧边栏返回常规浏览。所属分组保持打开,其隐藏条目在该次树挂载期间可见,因此选中行同时提供层级上下文与屏幕内位置。确认完成后,后续搜索或普通渲染不会重复滚动。 + +## 测试 + +UI 测试覆盖关闭 Workspace 中位于第六位的纯内容命中、对应的临时分组展开与滚动确认,以及单列表模式下的同类一次性滚动。组装层 Web 导航测试验证一次点击会清空搜索,并在常规树中只留下一个选中的 Session 行。长对话浏览器测试通过这次单击状态切换打开预置 Session。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 2d0ef48750..cdd2edfc04 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: bb41028ec4732a602ab1570b22b5c51160692ab2 -2026-07-27-web-session-search.zh.md: 9f03c42cfba018aa379ac2de6f78c6cdbfdb2ee0 +2026-07-27-web-session-search.md: c5d4194c7fad3f5aba23e0e40f12bb90a0b6d16e +2026-07-27-web-session-search.zh.md: 406333c3ae34defbe70769127ba4f0502967e8ae diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index bb41028ec4..c5d4194c7f 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -14,7 +14,7 @@ The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqli The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message` and `assistant/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. The first provider page requests 20 hits; a first-page `SESSION_QUERY_INVALID_LIMIT` halves that size through 10, 5, 2, and 1, retaining the learned size across continuations and stale-generation restarts. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Emitted snippets contain at most 240 Unicode code points; the Host and wire schema share the protocol bounds and code-point-safe truncation helper, while the wire schema independently enforces the snippet bound at client parse. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Limit probes and stale retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent limit or stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session, exits search, and reveals the selected row in the normal browser as defined by [Session search result reveal](../bug-fix/2026-09-03-session-search-result-reveal.md); it does not navigate to an exact event. The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives with the request and result types in `@deepseek-ai/dsh-api-session-controller/types`; Session Controller enforces it, and `ClientSessions.searchResultLimit` re-exposes it for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The Connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound and leave the same fact with two owners. @@ -41,4 +41,4 @@ The first content query can take longer because it imports and opens SQLite befo ## Testing -Host tests pin request and response validation, visible-session filtering, event/surface filters, result and snippet bounds, adaptive provider limits inside the shared call budget, learned-limit stale restarts, cursor and cross-page deduplication behavior, cancellation precedence, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; semantic extraction and SQLite/fixture search tests pin exclusion of reasoning-only text. The Node 22 compatibility gate builds the CLI and Web artifacts, boots the shipped `dsh web`/`AppCLIEntry` composition under plain Node with ambient warning suppression removed and an isolated temporary home/provider environment, waits for settled startup, and disposes it through the shipped signal path. Fixture, runtime, and UI tests pin match-centered bounded snippets, stateless delegation, the 500-code-unit query boundary, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, English copy, ARIA tree membership, row rendering, and navigation semantics. A keyless assembled Web test preserves the lazy-open config while seeding an unopened persisted conversation, finds it by visible message content through the SQLite index, captures the sidebar result, opens it, and verifies that the query remains. +Host tests pin request and response validation, visible-session filtering, event/surface filters, result and snippet bounds, adaptive provider limits inside the shared call budget, learned-limit stale restarts, cursor and cross-page deduplication behavior, cancellation precedence, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; semantic extraction and SQLite/fixture search tests pin exclusion of reasoning-only text. The Node 22 compatibility gate builds the CLI and Web artifacts, boots the shipped `dsh web`/`AppCLIEntry` composition under plain Node with ambient warning suppression removed and an isolated temporary home/provider environment, waits for settled startup, and disposes it through the shipped signal path. Fixture, runtime, and UI tests pin match-centered bounded snippets, stateless delegation, the 500-code-unit query boundary, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, English copy, ARIA tree membership, row rendering, and navigation semantics. A keyless assembled Web test preserves the lazy-open config while seeding an unopened persisted conversation, finds it by visible message content through the SQLite index, captures the sidebar result, opens it, and verifies that search exits with the selected Session visible in the normal browser. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 9f03c42cfb..406333c3ae 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -14,7 +14,7 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message` 和 `assistant/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行会打开对应 Session、退出搜索,并按照 [Session 搜索结果显露](../bug-fix/2026-09-03-session-search-result-reveal.zh.md)的规则在常规浏览器中显露选中行;不会跳转至确切事件。 结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 与请求和结果类型一起位于 `@deepseek-ai/dsh-api-session-controller/types`;Session Controller 强制执行它,`ClientSessions.searchResultLimit` 则把它重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明搜索动作。Connection handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,并让同一事实拥有两处归属。 @@ -41,4 +41,4 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 ## 测试 -宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为约定;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 +宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为约定;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证搜索退出且选中的 Session 在常规浏览器中可见。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml index dcb1144635..62e660bec4 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.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-11-workspace-sidebar-order-and-folding.md -2026-08-11-workspace-sidebar-order-and-folding.md: 4582631da79c946a2d3a713bc0d3a085ebed4cd0 -2026-08-11-workspace-sidebar-order-and-folding.zh.md: 6dfd443599b6c6696bbe12c19ebcde411d86a517 +2026-08-11-workspace-sidebar-order-and-folding.md: 9c9e2821a14ac8ee95c8349552c15e502c7d16dd +2026-08-11-workspace-sidebar-order-and-folding.zh.md: f45cd1f8209dc82689a65bcaabb67872749bb450 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md index 4582631da7..9c9e2821a1 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -20,7 +20,7 @@ The client installs a Workspace drag optimistically. Request and frame generatio ### Session folding and view order -Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts. +Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. A [Session search result reveal](../bug-fix/2026-09-03-session-search-result-reveal.md) opens the owning group and also reveals its hidden remainder for the current mount so the selected row cannot remain behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts. The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot. @@ -51,9 +51,9 @@ Search is a header action while collapsed and expands across the title and trail - Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account. - Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position. - A newly selected blank New Session row enters grouped and flat orders first once. Grouped folding renders it in addition to five non-blank rows until its first prompt, then applies the ordinary quota. -- Opening a Workspace never shows more than five non-blank Sessions without an explicit **Show more** gesture; the selected blank New Session may add one provisional row. Closing the Workspace resets only the transient gesture. +- Opening a Workspace never shows more than five non-blank Sessions without an explicit **Show more** gesture or search-result navigation; the selected blank New Session may add one provisional row. Closing the Workspace resets transient remainder expansion. - The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md). ## Testing -Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session. +Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, search-result reveal beyond the fold, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session. diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md index 6dfd443599..f45cd1f820 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -20,7 +20,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins ### Session 折叠与视图顺序 -每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 +每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。[Session 搜索结果显露](../bug-fix/2026-09-03-session-search-result-reveal.zh.md)会打开所属分组,并在当前挂载期间显露其余隐藏条目,避免选中行仍被五行折叠隐藏。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。 @@ -51,9 +51,9 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行 - Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。 - 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。 - 新选中的空白“新会话”行会在分组和单列表顺序中各置顶一次。分组折叠会在五条非空白行之外额外渲染该行,直至首条提示词落地后恢复普通配额。 -- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条非空白 Session;当前选中的空白“新会话”可以增加一条临时行。关闭分组只重置这项临时手势。 +- 未执行明确的**展开其余**手势或搜索结果导航时,打开 Workspace 最多显示五条非空白 Session;当前选中的空白“新会话”可以增加一条临时行。关闭分组会重置临时的其余条目展开状态。 - Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.zh.md)确立的手动顺序含义。 ## 测试 -领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。 +领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、折叠范围外的搜索结果显露、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。 diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index b8e9e4c042..d3726046cc 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -90,7 +90,6 @@ async function openSeed(page: Page): Promise { const resultCount = await results.count() if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`) await results.click() - await results.click() await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }) .last().waitFor({ timeout: 30_000 }) await nextPaint(page) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d5ecdabc70..c4f0a026b5 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -207,14 +207,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE) await result.click() - // Search navigation addresses the session, not a specific event, and the - // query remains until the user explicitly clears it. - await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL') + // Search navigation returns to the browser with the opened Session row exposed. + await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') + const selectedRow = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"][aria-selected="true"]') + await expect.poll(() => selectedRow.count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1) - await page.getByRole('button', { name: 'Clear search' }).click() - await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') - await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) }, 90_000) it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => { diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 2f684bac15..451bc1276e 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: 174d8368683f49d91dda1e35c413605b364a44e5 -README.zh.md: 1f589be35548374a562a56832672bc9add85b850 +README.md: c7e0e7e53886b6ae1a4de7c40ed6041a61280446 +README.zh.md: 695e89acb967924f0f617b7c17b6581ee38f8ba5 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 174d836868..c7e0e7e538 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -33,7 +33,7 @@ View options combine grouping with one browser-persisted Session order per accou ### Search -Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20 and opens the selected Session without clearing the query. +Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20. Choosing a result clears and collapses search, opens the Session, and scrolls its row into view in the configured browsing mode; grouped browsing also expands its Workspace and the full Session list when required. ### Managing sessions diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 1f589be355..695e89acb9 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 搜索 -折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,打开所选 Session 时不会清除查询。 +折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果。选择结果会清空并收起搜索、打开 Session,并在当前浏览模式中将其行滚动到可见区域;分组浏览还会按需展开所属 Workspace 和完整 Session 列表。 ### 管理会话 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 0f917100ff..0c2101bd0a 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -5,7 +5,7 @@ * except workspace Rename/Delete and session Rename/Fork/Archive; the session * and workspace hover cards are suppressed while a menu is open. */ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { HoverCard, IconAlarmClockOutline16, IconArchiveOutline20, IconBranchOutline16, @@ -370,12 +370,15 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { * @param props.onRename - open the session rename dialog (id + current title). * @param props.onFork - fork a session at its last completed turn. * @param props.onArchive - archive a session by id. + * @param props.onReveal - scroll this row into view after search navigation, then acknowledge it. * @param props.drag - optional draggable-row wiring. * @param props.flat - omit the empty status slot in the hierarchy-free flat list. * @param props.t - the browser root's locale seat. * @returns the session row. */ -export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }: { +export function SessionNodeItem({ + node, currentId, now, onOpen, onRename, onFork, onArchive, onReveal, drag, flat = false, t, +}: { node: SessionNode currentId: string | undefined now: number @@ -386,6 +389,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork onFork: (id: SessionNode['id']) => void /** Archive this session (row menu action; commits without a dialog). */ onArchive: (id: SessionNode['id']) => void + /** Scroll this row into view after search navigation, then acknowledge it. */ + onReveal?: (() => void) | undefined /** Present only on draggable rows (workspace-group sessions outside search). */ drag?: RowDragProps | undefined /** The row is rendered without a parent Workspace header. */ @@ -399,6 +404,12 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const primaryStatus = statuses[0] const showStatus = primaryStatus.state !== 'done' || row.completed const [menuOpen, setMenuOpen] = useState(false) + const rowRef = useRef(null) + useEffect(() => { + if (onReveal === undefined) return + rowRef.current?.scrollIntoView({ block: 'nearest' }) + onReveal() + }, [onReveal]) // Archive hides the row through the registry-global archive set and never // touches the session log, so it is not styled as destructive and needs no // confirmation dialog. @@ -411,6 +422,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = (
void /** Session order behavior: fixed after edits, or additionally promoted by user activity. */ orderBy: SessionOrderBy + /** One Session chosen from search that must be exposed and scrolled into view. */ + revealSessionId?: SessionId | undefined + /** Acknowledge that the chosen Session row has been revealed. */ + onSessionRevealed: (sessionId: SessionId) => void } /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ @@ -271,11 +275,18 @@ function SessionTree({ insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, + revealSessionId, onSessionRevealed, }: SessionTreeProps) { const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const current = list.current - const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) + const revealGroup = revealSessionId === undefined + ? undefined + : (workspaces.find(w => w.sessionIds.includes(revealSessionId))?.workspaceId as string | undefined) + ?? UNGROUPED_KEY + const [expandedSessionGroups, setExpandedSessionGroups] = useState( + () => revealGroup === undefined ? [] : [revealGroup], + ) // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) const sessionDropCommitted = useRef(false) @@ -564,6 +575,9 @@ function SessionTree({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} + onReveal={node.id === revealSessionId + ? () => { onSessionRevealed(node.id) } + : undefined} drag={dragProps} t={t} /> @@ -594,7 +608,8 @@ function SessionTree({ function FlatList({ useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, - orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t, + orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, + revealSessionId, onSessionRevealed, t, }: Pick< SessionTreeProps, | 'useSessions' @@ -609,6 +624,8 @@ function FlatList({ | 'sessionUpdatedAtByAccount' | 'syncSessionOrderAccount' | 'setSessionOrder' + | 'revealSessionId' + | 'onSessionRevealed' | 't' >) { const list = useSessions(s => s) @@ -683,6 +700,9 @@ function FlatList({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} + onReveal={node.id === revealSessionId + ? () => { onSessionRevealed(node.id) } + : undefined} flat drag={{ start: () => { @@ -874,6 +894,7 @@ export function WorkspaceBrowser({ // does not silently drop an in-progress filter. const [query, setQuery] = useState('') const [searchExpanded, setSearchExpanded] = useState(false) + const [revealSessionId, setRevealSessionId] = useState(undefined) const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ query: '', @@ -889,6 +910,19 @@ export function WorkspaceBrowser({ const wsPlusRef = useRef(null) const composingRef = useRef(false) + const openSearchResult = (sessionId: SessionId): void => { + const groupKey = (workspaces.find(workspace => workspace.sessionIds.includes(sessionId)) + ?.workspaceId as string | undefined) ?? UNGROUPED_KEY + actions.setGroupExpanded(groupKey, true) + setRevealSessionId(sessionId) + setQuery('') + setSearchExpanded(false) + open(sessionId) + } + const acknowledgeSessionReveal = (sessionId: SessionId): void => { + setRevealSessionId(current => current === sessionId ? undefined : current) + } + // Rail search = expand + land in the search box: the flag arms before the // expand request; once the shell flips wide the input mounts and takes focus. const [searchOnExpand, setSearchOnExpand] = useState(false) @@ -1206,7 +1240,7 @@ export function WorkspaceBrowser({ ) @@ -1250,6 +1286,8 @@ export function WorkspaceBrowser({ insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} orderBy={orderBy} + revealSessionId={revealSessionId} + onSessionRevealed={acknowledgeSessionReveal} home={home} t={t} onRenameRequest={(workspaceId, currentTitle) => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 8ee31d37c9..482a51d5bc 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -16,7 +16,13 @@ import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' import { zh } from '../src/client/locales.ts' afterEach(cleanup) -beforeEach(() => { localStorage.clear(); createWorkspaceViewStore().create().actions.setOrderBy('manual') }) +const scrollIntoView = vi.fn() +beforeEach(() => { + localStorage.clear() + createWorkspaceViewStore().create().actions.setOrderBy('manual') + Element.prototype.scrollIntoView = scrollIntoView + scrollIntoView.mockClear() +}) // The seat's key domain is workspace ∪ common; the stub mirrors the real // lookup chain (namespace, then common vocabulary, then the key). @@ -664,7 +670,7 @@ describe('WorkspaceBrowser', () => { expect(input.value).toBe('kept') }) - it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => { + it('opens a Host content hit, exits search, and reveals its hidden grouped row', async () => { vi.useFakeTimers() try { const open = vi.fn() @@ -672,12 +678,19 @@ describe('WorkspaceBrowser', () => { items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }], hasMore: true, })) - mount({ + const b = mount({ useSessions: hook(sessionState([ + summary('newest-1', 6), + summary('newest-2', 5), + summary('newest-3', 4), + summary('newest-4', 3), + summary('newest-5', 2), summary('body-hit', 1, { displayTitle: 'Research notes' }), ])), useWorkspaces: hook(workspaceState([ - workspace('research', ['body-hit'], 'Research Workspace'), + workspace('research', [ + 'newest-1', 'newest-2', 'newest-3', 'newest-4', 'newest-5', 'body-hit', + ], 'Research Workspace'), ])), open, searchSessions, @@ -696,12 +709,45 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('仅显示前 20 条结果,请缩小搜索范围。')).toBeTruthy() fireEvent.click(screen.getByRole('treeitem')) expect(open).toHaveBeenCalledWith(sid('body-hit')) - expect(input.value).toBe('waterfall token') + expect(input.value).toBe('') + expect(screen.queryByRole('tree', { name: '搜索结果' })).toBeNull() + expect(screen.getByRole('tree', { name: '会话' })).toBeTruthy() + expect(b.store.getSnapshot().groupExpansion).toEqual({ research: true }) + const targetRow = screen.getByText('Research notes').closest('[role="treeitem"]') + expect(targetRow).toBeTruthy() + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }) + expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow) } finally { vi.useRealTimers() } }) + it('returns search navigation to the flat list and scrolls to the selected row', () => { + const open = vi.fn() + mount({ + useSessions: hook(sessionState([ + summary('target', 2, { displayTitle: 'Needle session' }), + summary('other', 1), + ])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['target', 'other'])])), + open, + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(open).toHaveBeenCalledWith(sid('target')) + expect(input.value).toBe('') + expect(screen.queryByRole('tree', { name: '搜索结果' })).toBeNull() + const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]') + expect(targetRow).toBeTruthy() + expect(screen.queryByText('alpha')).toBeNull() + expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow) + }) + it('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => { vi.useFakeTimers() try { From 4f0706a335c0e092187423579a69420ad31bf1d6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 3 Sep 2026 15:03:38 +0800 Subject: [PATCH 02/12] fix(web): address session reveal review --- ...-03-session-search-result-reveal.i18n.yaml | 4 +- ...2026-09-03-session-search-result-reveal.md | 1 + ...6-09-03-session-search-result-reveal.zh.md | 1 + .agents/notes/archived/manifest.json | 3 + .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 2 +- .../2026-07-27-web-session-search.zh.md | 2 +- ...kspace-sidebar-order-and-folding.i18n.yaml | 4 +- ...-11-workspace-sidebar-order-and-folding.md | 2 +- ...-workspace-sidebar-order-and-folding.zh.md | 2 +- .../src/client/rows/WorkspaceBrowser.tsx | 45 +++++++---- .../client/ui-workspace/src/client/tree.ts | 17 ++++- .../ui-workspace/tests/tree.client.spec.ts | 10 ++- .../tests/workspace-browser.client.spec.tsx | 74 ++++++++++++++++++- 14 files changed, 141 insertions(+), 30 deletions(-) rename .agents/notes/{implemented => archived}/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/bug-fix/2026-09-03-session-search-result-reveal.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-09-03-session-search-result-reveal.zh.md (99%) diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml similarity index 68% rename from .agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml index c1419c8fff..3ec848b61f 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.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/bug-fix/2026-09-03-session-search-result-reveal.md -2026-09-03-session-search-result-reveal.md: 9e07db5ffb1fc8ef011fa7b57730b7238709860b -2026-09-03-session-search-result-reveal.zh.md: 53af95eb49d764113ea4ef320df11ae826361ecd +2026-09-03-session-search-result-reveal.md: 71511ba84f6e33eece8c64c4a20a8ea5eac7ceb9 +2026-09-03-session-search-result-reveal.zh.md: aeea1b6b31f61771579ad0061eae858d23dcdcd8 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.md b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.md rename to .agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md index 9e07db5ffb..71511ba84f 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.md +++ b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md @@ -1,6 +1,7 @@ # Agent Note: Session search result reveal Status: implemented +Archived: 2026-09-03 English | [中文](2026-09-03-session-search-result-reveal.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.zh.md b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.zh.md rename to .agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md index 53af95eb49..aeea1b6b31 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.zh.md +++ b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md @@ -1,6 +1,7 @@ # Agent Note: Session 搜索结果显露 Status: implemented +Archived: 2026-09-03 [English](2026-09-03-session-search-result-reveal.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index e2bf423d56..8a28d94099 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -139,6 +139,9 @@ "bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d", "bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23", "bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16", + "bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:36fea5421f6f9a3e20a32db8283db7f532433267c1d17ed49b0ec8dbd1fc1765", + "bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:1c80c4cea1193ecdfccd17efc5702255af17ea68c3662b40bced7c70558bf231", + "bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:bf0ff3d9bc262ec83d9930d10da839fe9d1c47b483dc9406e6d065876f9e0cdc", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index cdd2edfc04..d16f8155e1 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: c5d4194c7fad3f5aba23e0e40f12bb90a0b6d16e -2026-07-27-web-session-search.zh.md: 406333c3ae34defbe70769127ba4f0502967e8ae +2026-07-27-web-session-search.md: d7a979a278113f9dcc170ec12f9d0fb37e1d55b7 +2026-07-27-web-session-search.zh.md: ba125ba884a1c93f3d2ac23b7f750c6ca216ed5f diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index c5d4194c7f..d7a979a278 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -14,7 +14,7 @@ The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqli The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message` and `assistant/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. The first provider page requests 20 hits; a first-page `SESSION_QUERY_INVALID_LIMIT` halves that size through 10, 5, 2, and 1, retaining the learned size across continuations and stale-generation restarts. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Emitted snippets contain at most 240 Unicode code points; the Host and wire schema share the protocol bounds and code-point-safe truncation helper, while the wire schema independently enforces the snippet bound at client parse. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Limit probes and stale retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent limit or stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session, exits search, and reveals the selected row in the normal browser as defined by [Session search result reveal](../bug-fix/2026-09-03-session-search-result-reveal.md); it does not navigate to an exact event. +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session, exits search, and reveals the selected row in the normal browser; it does not navigate to an exact event. The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives with the request and result types in `@deepseek-ai/dsh-api-session-controller/types`; Session Controller enforces it, and `ClientSessions.searchResultLimit` re-exposes it for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The Connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound and leave the same fact with two owners. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 406333c3ae..ba125ba884 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -14,7 +14,7 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message` 和 `assistant/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行会打开对应 Session、退出搜索,并按照 [Session 搜索结果显露](../bug-fix/2026-09-03-session-search-result-reveal.zh.md)的规则在常规浏览器中显露选中行;不会跳转至确切事件。 +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行会打开对应 Session、退出搜索,并在常规浏览器中显露选中行;不会跳转至确切事件。 结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 与请求和结果类型一起位于 `@deepseek-ai/dsh-api-session-controller/types`;Session Controller 强制执行它,`ClientSessions.searchResultLimit` 则把它重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明搜索动作。Connection handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,并让同一事实拥有两处归属。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml index 62e660bec4..125a027c08 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.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-11-workspace-sidebar-order-and-folding.md -2026-08-11-workspace-sidebar-order-and-folding.md: 9c9e2821a14ac8ee95c8349552c15e502c7d16dd -2026-08-11-workspace-sidebar-order-and-folding.zh.md: f45cd1f8209dc82689a65bcaabb67872749bb450 +2026-08-11-workspace-sidebar-order-and-folding.md: 99b0dcaf978411345868a2359c537b677ff771f0 +2026-08-11-workspace-sidebar-order-and-folding.zh.md: b8381e28a62859bdc91aa037e79a276761f9132d diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md index 9c9e2821a1..99b0dcaf97 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -20,7 +20,7 @@ The client installs a Workspace drag optimistically. Request and frame generatio ### Session folding and view order -Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. A [Session search result reveal](../bug-fix/2026-09-03-session-search-result-reveal.md) opens the owning group and also reveals its hidden remainder for the current mount so the selected row cannot remain behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts. +Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. Selecting a Session search result opens its owning group after Workspace membership is ready and reveals the hidden remainder for the current mount only when the target is behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts. The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot. diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md index f45cd1f820..b8381e28a6 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -20,7 +20,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins ### Session 折叠与视图顺序 -每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。[Session 搜索结果显露](../bug-fix/2026-09-03-session-search-result-reveal.zh.md)会打开所属分组,并在当前挂载期间显露其余隐藏条目,避免选中行仍被五行折叠隐藏。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 +每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。选择 Session 搜索结果会等 Workspace 归属信息就绪后打开所属分组,并且仅当目标位于五行折叠之后时,才会在当前挂载期间显露其余隐藏条目。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。 diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 522b24e069..45cece1bba 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -22,7 +22,9 @@ import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace- import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { SessionNode, SessionOrderBy } from '../tree.ts' -import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from '../tree.ts' +import { + deriveFlat, deriveGroups, deriveSearchResults, owningGroupKey, UNGROUPED_KEY, +} from '../tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx' import { FLAT_SESSION_ORDER_KEY } from '../stores.ts' import { WorkspacePickFlow } from '../WorkspacePicker.tsx' @@ -238,6 +240,8 @@ type SessionTreeProps = Pick< /** Host account home for POSIX hover-path abbreviation. */ home?: string | undefined workspaces: readonly WorkspaceView[] + /** Whether Workspace membership has received its first complete Host baseline. */ + workspaceReady: boolean /** Explicit persisted zero-or-five-session state by Workspace group. */ groupExpansion: Readonly> /** Persist one Workspace group's zero-or-five-session state. */ @@ -271,6 +275,7 @@ type SessionTreeProps = Pick< /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds, + workspaceReady, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, @@ -280,13 +285,10 @@ function SessionTree({ const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const current = list.current - const revealGroup = revealSessionId === undefined + const revealGroup = revealSessionId === undefined || !workspaceReady ? undefined - : (workspaces.find(w => w.sessionIds.includes(revealSessionId))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY - const [expandedSessionGroups, setExpandedSessionGroups] = useState( - () => revealGroup === undefined ? [] : [revealGroup], - ) + : owningGroupKey(workspaces, revealSessionId) + const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) const sessionDropCommitted = useRef(false) @@ -295,10 +297,9 @@ function SessionTree({ const previousOrderBy = useRef(orderBy) const nativeDragActive = drag !== null || workspaceDrag !== null useNativeDragAcceptance(nativeDragActive) - const currentGroup = current === undefined + const currentGroup = current === undefined || !workspaceReady ? undefined - : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY + : owningGroupKey(workspaces, current) useEffect(() => { if (current === undefined || currentGroup === undefined || Object.hasOwn(groupExpansion, currentGroup)) return setGroupExpanded(currentGroup, true) @@ -358,6 +359,17 @@ function SessionTree({ }), [list, orderedWorkspaces, archivedSessionIds, pendingInteractions, expandedGroups, sessionOrderByAccount], ) + useEffect(() => { + if (revealGroup === undefined || groupExpansion[revealGroup] === true) return + setGroupExpanded(revealGroup, true) + }, [groupExpansion, revealGroup, setGroupExpanded]) + useEffect(() => { + if (revealSessionId === undefined || revealGroup === undefined) return + const group = groups.find(candidate => candidate.key === revealGroup) + if (group === undefined || !group.expanded || !group.sessions.some(row => row.id === revealSessionId)) return + if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return + setExpandedSessionGroups(keys => keys.includes(revealGroup) ? keys : [...keys, revealGroup]) + }, [groups, revealGroup, revealSessionId]) const now = Date.now() const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { if (sessionDropCommitted.current) return @@ -575,7 +587,7 @@ function SessionTree({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} - onReveal={node.id === revealSessionId + onReveal={node.id === revealSessionId && group.key === revealGroup ? () => { onSessionRevealed(node.id) } : undefined} drag={dragProps} @@ -861,9 +873,9 @@ export function WorkspaceBrowser({ return current !== undefined && state.byId[current]?.blank === true ? current : undefined }) const currentBlankAccount = currentBlankSessionId === undefined + || workspacePhase !== 'ready' ? undefined - : (workspaces.find(workspace => workspace.sessionIds.includes(currentBlankSessionId)) - ?.workspaceId as string | undefined) ?? UNGROUPED_KEY + : owningGroupKey(workspaces, currentBlankSessionId) const promotedBlank = useRef<{ sessionId: SessionId; accountKey: string } | undefined>(undefined) useEffect(() => { if (currentBlankSessionId === undefined || currentBlankAccount === undefined) { @@ -911,9 +923,6 @@ export function WorkspaceBrowser({ const composingRef = useRef(false) const openSearchResult = (sessionId: SessionId): void => { - const groupKey = (workspaces.find(workspace => workspace.sessionIds.includes(sessionId)) - ?.workspaceId as string | undefined) ?? UNGROUPED_KEY - actions.setGroupExpanded(groupKey, true) setRevealSessionId(sessionId) setQuery('') setSearchExpanded(false) @@ -922,6 +931,9 @@ export function WorkspaceBrowser({ const acknowledgeSessionReveal = (sessionId: SessionId): void => { setRevealSessionId(current => current === sessionId ? undefined : current) } + useEffect(() => { + if (normalizedQuery !== '') setRevealSessionId(undefined) + }, [normalizedQuery]) // Rail search = expand + land in the search box: the flag arms before the // expand request; once the shell flips wide the input mounts and takes focus. @@ -1274,6 +1286,7 @@ export function WorkspaceBrowser({ onSessionArchive={onSessionArchive} forkSession={forkSession} workspaces={workspaces} + workspaceReady={workspacePhase === 'ready'} groupExpansion={groupExpansion} setGroupExpanded={actions.setGroupExpanded} sessionOrderByAccount={sessionOrderByAccount} diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index ec921306f9..37c6d50e38 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -20,6 +20,20 @@ import { /** Group key for Sessions outside every Workspace. */ export const UNGROUPED_KEY = '' +/** + * Resolve the Workspace browser group that owns one Session. + * @param workspaces - authoritative Workspace membership. + * @param sessionId - Session whose browser group is required. + * @returns owning Workspace id, or {@link UNGROUPED_KEY} when no Workspace accounts for it. + */ +export function owningGroupKey( + workspaces: readonly WorkspaceView[], + sessionId: SessionId, +): string { + return (workspaces.find(workspace => workspace.sessionIds.includes(sessionId)) + ?.workspaceId as string | undefined) ?? UNGROUPED_KEY +} + /** Pending interaction kinds with dedicated Workspace-row presentation. */ export type SessionPendingInteractionStatus = 'approval' | 'plan-review' | 'question' type SessionPendingInteractions = ReadonlyMap @@ -287,8 +301,7 @@ export function deriveGroups( const descendants = indexSubagentDescendants(list.byId) const currentGroup = list.current === undefined ? undefined - : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY + : owningGroupKey(workspaces, list.current) const groups: GroupNode[] = [] for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) { const expanded = expandedGroups.has(g.key) diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 1451b1450d..8f5326134b 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -5,7 +5,7 @@ import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-s import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, + deriveFlat, deriveGroups, deriveSearchResults, owningGroupKey, workspaceLabel, UNGROUPED_KEY, } from '../src/client/tree.ts' import { createWorkspaceViewStore } from '../src/client/stores.ts' @@ -40,6 +40,14 @@ const schedule = (id: string, scheduledAt: string): ScheduleRecord => ({ scheduledAt, }) +describe('owningGroupKey', () => { + it('returns the owning Workspace id or the Ungrouped key', () => { + const workspaces = [workspace('first', ['owned'])] + expect(owningGroupKey(workspaces, sid('owned'))).toBe('first') + expect(owningGroupKey(workspaces, sid('loose'))).toBe(UNGROUPED_KEY) + }) +}) + describe('deriveGroups', () => { it('keeps Host Workspace and sessionIds order without Client recency sorting', () => { const sessions = list(summary('newer', 20), summary('older', 10)) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 482a51d5bc..7c6562a0c6 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -723,9 +723,80 @@ describe('WorkspaceBrowser', () => { } }) + it('waits for authoritative Workspace membership before revealing a grouped search result', async () => { + const sessions = sessionState([ + summary('newest-1', 6), + summary('newest-2', 5), + summary('newest-3', 4), + summary('newest-4', 3), + summary('newest-5', 2), + summary('target', 1, { displayTitle: 'Needle session' }), + ]) + const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const } + const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) }) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(scrollIntoView).not.toHaveBeenCalled() + expect(b.store.getSnapshot().groupExpansion).toEqual({}) + + rerender(b, { + useWorkspaces: hook(workspaceState([workspace('research', [ + 'newest-1', 'newest-2', 'newest-3', 'newest-4', 'newest-5', 'target', + ])])), + }) + await waitFor(() => { + expect(b.store.getSnapshot().groupExpansion).toEqual({ research: true }) + expect(screen.getByText('Needle session')).toBeTruthy() + }) + const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]') + expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow) + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + }) + + it('keeps the bounded group projection when the revealed result is already within it', () => { + const sessions = sessionState([ + summary('target', 6, { displayTitle: 'Needle session' }), + summary('second', 5), + summary('third', 4), + summary('fourth', 3), + summary('fifth', 2), + summary('hidden', 1), + ]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('research', [...sessions.ids])])), + }) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(screen.getByText('Needle session')).toBeTruthy() + expect(screen.queryByText('hidden')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 1 个会话' })).toBeTruthy() + expect(scrollIntoView).toHaveBeenCalledOnce() + }) + + it('cancels a pending row reveal when a new search begins', () => { + const sessions = sessionState([summary('target', 1, { displayTitle: 'Needle session' })]) + const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const } + const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) }) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + fireEvent.change(input, { target: { value: 'another query' } }) + fireEvent.change(input, { target: { value: '' } }) + rerender(b, { useWorkspaces: hook(workspaceState([workspace('research', ['target'])])) }) + + expect(b.store.getSnapshot().groupExpansion).toEqual({}) + expect(scrollIntoView).not.toHaveBeenCalled() + }) + it('returns search navigation to the flat list and scrolls to the selected row', () => { const open = vi.fn() - mount({ + const b = mount({ useSessions: hook(sessionState([ summary('target', 2, { displayTitle: 'Needle session' }), summary('other', 1), @@ -746,6 +817,7 @@ describe('WorkspaceBrowser', () => { expect(targetRow).toBeTruthy() expect(screen.queryByText('alpha')).toBeNull() expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow) + expect(b.store.getSnapshot().groupExpansion).toEqual({}) }) it('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => { From a22f2105e059186dcaba063235ed16982ee6dbe0 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 3 Sep 2026 16:17:43 +0800 Subject: [PATCH 03/12] fix(test): stabilize trajectory history pagination --- .../2026-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.md | 2 ++ .../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 ++ apps/web/tests/trajectory-virtualization.e2e.ts | 10 ++++++++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 533f2c268b..dda53c2762 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 642a2a36741468f2af1de55913c96f7146856ed4 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 72b002c3be4a2ab1dbcdd279a1c8c485b5af3b3e +2026-07-24-web-gui-browser-e2e-lane.md: baea3d5623734dfde3c512a923ef08210407619c +2026-07-24-web-gui-browser-e2e-lane.zh.md: aaab9c6f49aeb90ebb96f0f895a4f68011881684 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 642a2a3674..baea3d5623 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -28,6 +28,8 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. +Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat. + Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home. ### Expected outputs diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 72b002c3be..aaab9c6f49 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -28,6 +28,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 +分页驱动会等待交互式加载行退出 pending 状态,并在滚动前记录请求前的行数。因此,即时提交的常驻页仍然可观测,不会变成一次滚动手势不会重复触发的请求基线。 + 每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。 ### 预期输出 diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index 9f17854c06..c073e1a3aa 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -34,6 +34,7 @@ const FIXTURE = createChatScrollFixture({ turns: 88, }) const MAX_MOUNTED_ROWS = 160 +const MAX_STREAM_SCROLL_CALLS = 8 const GEOMETRY_TOLERANCE = 2 const STREAM_MARKER = 'TRAJECTORY_VIRTUAL_STREAM_FINISHED' const STREAM_TEXT = Array.from( @@ -153,10 +154,15 @@ async function rowTop(page: Page, key: string): Promise { async function loadToFirstTurn(page: Page): Promise { const marker = FIXTURE.markers.user(1) + const loadMore = page.locator('[data-history-load] button') for (let attempt = 0; attempt < 12; attempt += 1) { + if (await page.getByText(marker, { exact: false }).count() > 0) return + await expect.poll(() => loadMore.evaluateAll(buttons => + buttons.length === 0 || !(buttons[0] as HTMLButtonElement).disabled, + ), { timeout: 15_000 }).toBe(true) + const before = await logicalRows(page) await scrollToRatio(page, 0) if (await page.getByText(marker, { exact: false }).count() > 0) return - const before = await logicalRows(page) const anchor = await firstVisibleRow(page) await expect.poll(async () => ({ marker: await page.getByText(marker, { exact: false }).count() > 0, @@ -337,7 +343,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { return (window as Window & { __trajectoryScrollCalls?: number }) .__trajectoryScrollCalls ?? 0 }) - expect(streamingScrollCalls).toBeLessThanOrEqual(5) + expect(streamingScrollCalls).toBeLessThanOrEqual(MAX_STREAM_SCROLL_CALLS) expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) expect({ pageErrors: tripwire.pageErrors, From 0364343a7efbc62f397c8efa30624468c85ca422 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 3 Sep 2026 16:19:18 +0800 Subject: [PATCH 04/12] test: remove dsh-* temp dirs created by unit tests at teardown Spec files that create /tmp/dsh-* directories via mkdtemp now track and delete them in afterEach/afterAll; module-scope fixture dirs (executor spill dirs) are removed in afterAll. The file list came from the observed-residue inventory on the self-hosted CI host: only specs whose dirs actually accumulated were leak sources (issue #3134), superseding the kept-but-unmerged CI sweep branch per the #3233 review decision. Product per-process spill roots (dsh-subprocess-local spawn, dsh-spill-local store) register a process-exit handler that removes the memoized dir, so processes that used the spawn/spill path clean up on normal exit. A SIGKILLed process cannot run in-process teardown; the machine-side timer remains the backstop for that path. Agent Note: .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md --- ...08-28-test-temp-dir-self-cleanup.i18n.yaml | 6 +++ .../2026-08-28-test-temp-dir-self-cleanup.md | 39 +++++++++++++++++++ ...026-08-28-test-temp-dir-self-cleanup.zh.md | 39 +++++++++++++++++++ apps/web/tests/agent-preset-authoring.e2e.ts | 1 + apps/web/tests/agent-preset-selection.e2e.ts | 3 +- .../tests/agent.host.spec.ts | 12 +++++- .../tests/session-presets.host.spec.ts | 14 ++++++- .../tests/workspace-controller.host.spec.ts | 7 +++- packages/boot/app-boot/tests/app-boot.spec.ts | 15 +++++-- .../boot/app-boot/tests/config-dump.spec.ts | 15 +++++-- .../boot/app-boot/tests/config-reload.spec.ts | 12 +++++- .../boot/app-boot/tests/hmr-config.spec.ts | 13 ++++++- packages/boot/app-boot/tests/profile.spec.ts | 13 ++++++- .../boot/app-boot/tests/user-patches.spec.ts | 15 +++++-- packages/boot/cmdline/tests/cmdline.spec.ts | 7 +++- .../bundle/headless/tests/startup.spec.ts | 7 +++- packages/bundle/web-app/tests/startup.spec.ts | 7 +++- .../llm/llm-deepseek/tests/file-store.spec.ts | 27 ++++++++++++- .../llm-deepseek/tests/upload-index.spec.ts | 17 +++++++- .../agent-presets/tests/authoring.spec.ts | 16 ++++++-- .../tests/composition-inventory.spec.ts | 12 +++++- .../agent-presets/tests/discovery.spec.ts | 21 +++++++++- .../agent-presets/tests/metadata.spec.ts | 11 +++++- .../preset/agent-presets/tests/mount.spec.ts | 14 ++++++- .../preset/agent-presets/tests/remote.spec.ts | 15 ++++++- .../agent-presets/tests/settings.spec.ts | 12 +++++- .../agent-presets/tests/shipped-root.spec.ts | 9 +++-- .../agent-presets/tests/user-root.spec.ts | 10 ++++- .../sandbox/sandbox-local/tests/local.spec.ts | 19 +++++++-- packages/sandbox/sandbox/tests/roots.spec.ts | 23 +++++++---- .../shell/bash-local/tests/executor.spec.ts | 8 +++- .../shell/pwsh-local/tests/executor.spec.ts | 18 ++++++++- .../tests/skill-filesystem-watcher.spec.ts | 12 +++++- .../tests/skill-filesystem.spec.ts | 12 +++++- .../skill/tool-skill/tests/tool-skill.spec.ts | 14 +++++-- packages/spill/spill-local/src/store.ts | 9 ++++- .../subprocess/subprocess-local/src/spawn.ts | 9 ++++- .../subprocess-local/tests/spawn.spec.ts | 8 +++- scripts/coverage-partitions.spec.ts | 13 +++++-- 39 files changed, 461 insertions(+), 73 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md create mode 100644 .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml new file mode 100644 index 0000000000..a8eaf5c011 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.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/process/2026-08-28-test-temp-dir-self-cleanup.md +2026-08-28-test-temp-dir-self-cleanup.md: e8e0c5e2db716ff9a63ef55ae1af07a66eaf28a0 +2026-08-28-test-temp-dir-self-cleanup.zh.md: cdc0dfa2b6a00fc11703743c51326238bf644fb1 diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md new file mode 100644 index 0000000000..e8e0c5e2db --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md @@ -0,0 +1,39 @@ +# Agent Note: Unit tests remove the dsh-* temp dirs they create + +Status: implemented + +English | [中文](2026-08-28-test-temp-dir-self-cleanup.zh.md) + +## Problem + +Test processes create `/tmp/dsh-*` directories with `mkdtemp(join(tmpdir(), 'dsh-*'))` and leave them behind. On the self-hosted Linux CI host (32 runner instances sharing one `/tmp`) the residue exhausted the root partition's inode capacity twice (issue #3134, 2026-08-13 and 2026-08-26). The machine-side `dsh-tmp-sweep` timer and the CI lane sweep (kept, unmerged, on branch `fix/ci-tmp-residue-cleanup`) remove residue after the fact but leave the producing defect in place. Human review of #3233 (2026-08-28) rejected the sweep: unit tests must clean up the directories they create instead. + +## Decision + +Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning test's teardown: + +- Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in `afterEach`/`afterAll` (`rm`/`rmSync` with `recursive: true, force: true`), the convention already used across the session packages. Root-creating helpers (`tmp()`, `tempDir()`, `fakeLauncher()`, harness functions) register the root at creation, so every caller is covered at one point. +- Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in `afterAll` after the last test. +- The file list came from the observed-residue inventory on the CI host (a template histogram of current `/tmp/dsh-*` dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged. +- Product per-process spill roots (`privateSpillDir` in `dsh-subprocess-local/spawn`, `privateRoot` in `dsh-spill-local/store`) register a `process.once('exit')` handler that removes the memoized directory, so every process that used the spawn/spill path cleans up on normal exit. + +## Verification + +- Targeted local run of every changed spec (32 files, 700 tests) passed, including the suites that exercise the changed product sources. +- CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g. `dsh-profile-`, `dsh-app-boot-`, `dsh-presets-*`, `dsh-upload-index-`) should no longer appear in fresh `/tmp` residue on the CI host. + +## Alternatives considered + +### Keep the sweep-only approach (rejected in review) + +Sweep steps and timers delete residue after it exists; they do not stop local runs from accumulating, and a machine sweep cannot distinguish a dead run's residue from a live one's. The reviewer decision was per-test cleanup, implemented here for the normal-exit path. + +### Introduce a shared temp-dir helper package + +Not chosen: the files that leak each create roots through their own small helpers, and tracking them at those helpers is a per-file one-point change. A new test-support package would add a dependency without reducing the per-file audit. + +## Consequences + +- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown, and per-process spill roots are removed when their process exits normally. +- Cost: a process killed with SIGKILL (a cancelled run, a timeout kill) cannot run any in-process teardown; its in-flight residue remains. The machine-side timer stays as the backstop for that path. +- Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned per-process roots are covered by the exit handler in the process that created them. diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md new file mode 100644 index 0000000000..cdc0dfa2b6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md @@ -0,0 +1,39 @@ +# Agent Note:单测删除自己创建的 dsh-* 临时目录 + +Status: implemented + +[English](2026-08-28-test-temp-dir-self-cleanup.md) | 中文 + +## Problem + +测试进程用 `mkdtemp(join(tmpdir(), 'dsh-*'))` 创建 `/tmp/dsh-*` 目录后不清理。在自托管 Linux CI 主机上(32 个 runner 实例共享一个 `/tmp`),残留两次耗尽根分区 inode(issue #3134,2026-08-13 与 2026-08-26)。机器侧 `dsh-tmp-sweep` timer 与 CI lane sweep(保留未合并,在分支 `fix/ci-tmp-residue-cleanup` 上)都是事后删除残留,未修掉产生残留的缺陷本体。人类 review #3233(2026-08-28)否决了 sweep:单测应改为自己清理创建的目录。 + +## Decision + +为 spec 文件创建的每个 `dsh-*` 临时目录补上删除路径,挂在所属测试的 teardown 上: + +- 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在 `afterEach`/`afterAll` 里删除(`rm`/`rmSync` 带 `recursive: true, force: true`)——与 session 包既有的 `roots.splice(0)` 约定一致。创建 root 的 helper(`tmp()`、`tempDir()`、`fakeLauncher()`、harness 函数)在创建处登记,一个点覆盖全部调用方。 +- 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的 `afterAll` 里删除。 +- 目标文件清单来自 CI 主机上的残留实测清单(当前 `/tmp/dsh-*` 目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。 +- 产品侧每进程 spill root(`dsh-subprocess-local/spawn` 的 `privateSpillDir`、`dsh-spill-local/store` 的 `privateRoot`)注册 `process.once('exit')` handler,在进程正常退出时删除记忆化的目录——凡走过 spawn/spill 路径的进程都会在正常结束时清理。 + +## Verification + +- 本地定向跑过全部改动 spec(32 个文件、700 个测试)通过,含直接使用改动后产品源码的套件。 +- CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如 `dsh-profile-`、`dsh-app-boot-`、`dsh-presets-*`、`dsh-upload-index-`)应不再出现在 CI 主机的新鲜 `/tmp` 残留里。 + +## Alternatives considered + +### 保留纯 sweep 方案(review 否决) + +Sweep 步骤与 timer 只删已存在的残留;本地运行仍会累积,机器 sweep 也区分不了已死 run 的残留与存活 run 的目录。review 的决定是逐测试清理,本实现覆盖正常结束路径。 + +### 引入共享临时目录 helper 包 + +未选:泄漏文件各自通过自己的小 helper 创建 root,在那些 helper 处登记是每个文件单点改动;新增 test-support 包只会增加依赖,不减少逐文件审计量。 + +## Consequences + +- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;每进程 spill root 在其进程正常退出时删除。 +- 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。 +- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有每进程 root 由创建它的进程的 exit handler 覆盖。 diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 72b8317fe4..4155fd338e 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -76,6 +76,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { afterAll(async () => { await browser?.close() await scaffold?.close() + await rm(userRoot, { recursive: true, force: true }) }) it('offers the roster with copy as the only way to create', async () => { diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 432c34a201..49e302c4c3 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -9,7 +9,7 @@ // // Zero model calls: no replay fixture mounts, so a stray stream fails loud. import { fileURLToPath } from 'node:url' -import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -232,6 +232,7 @@ describe('web e2e: agent-preset selection', () => { afterAll(async () => { await browser?.close() await scaffold?.close() + await rm(presetRoot, { recursive: true, force: true }) }) it('offers the chip on the new-session screen, beside the workspace picker', async () => { diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 566ce048c8..f266b89fa9 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -22,8 +22,12 @@ import { installSessionReadTestServices, testSessionPersistence } from './test-r const roots: Context[] = [] +/** Session cwd roots created per test, removed after their context settles. */ +const tempDirs: string[] = [] + afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> { @@ -297,6 +301,7 @@ describe('ApiSession create or adoption', () => { it('shares one in-flight creation between concurrent callers', async () => { const { ctx, agents } = await harness() const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-')) + tempDirs.push(cwd) const meta = header('concurrent-create', cwd) const created = unpublishedAgent(ctx, meta) let release!: () => void @@ -317,6 +322,7 @@ describe('ApiSession create or adoption', () => { it('accepts a raced ordinary creation and rejects a raced attached child', async () => { const ordinary = await harness() const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-')) + tempDirs.push(cwd) const ordinaryMeta = header('create-race', cwd) const winner = agent(ordinary.ctx, ordinaryMeta) vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => { @@ -328,6 +334,7 @@ describe('ApiSession create or adoption', () => { const child = await harness() const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-')) + tempDirs.push(childCwd) const childId = SessionId('create-child-race') vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => { child.ctx.sessions.create(childId, { @@ -342,6 +349,7 @@ describe('ApiSession create or adoption', () => { it('validates ownership and cwd on the Agent returned by creation', async () => { const child = await harness() const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-')) + tempDirs.push(childCwd) const childMeta = { ...header('returned-child', childCwd), parentSession: SessionId('parent'), @@ -357,6 +365,7 @@ describe('ApiSession create or adoption', () => { const wrong = await harness() const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-')) + tempDirs.push(requestedCwd) const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other')) vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({ agent: wrongAgent, @@ -437,6 +446,7 @@ describe('ApiSession create or adoption', () => { it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => { const { agents } = await harness() const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-')) + tempDirs.push(parent) const file = join(parent, 'file') writeFileSync(file, 'not a directory') await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index a30c72c86b..0b6e872653 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -1,6 +1,6 @@ /** Session creation and adoption rules for Agent preset identity. */ -import { mkdtempSync, realpathSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -10,9 +10,17 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { createSessionTestRemote } from './test-remote.ts' +/** Booted contexts and their temp roots, torn down after each test. */ +const contexts: Context[] = [] +const tempDirs: string[] = [] +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + function stubAgent(session: Session): Agent { return { id: session.id, session, status: 'idle' } as unknown as Agent } @@ -42,7 +50,9 @@ function roster(ids: readonly string[]): unknown { async function harness(presets?: readonly string[]) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-'))) + tempDirs.push(cwd) const ctx = new Context() + contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) if (presets !== undefined) { diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index dab997847b..3bcd3b0d04 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -22,8 +22,12 @@ declare module '@deepseek-ai/dsh-typert-protocol' { const roots: Context[] = [] +/** Workspace roots created per test, removed after their context settles. */ +const tempDirs: string[] = [] + afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) interface Deferred { @@ -39,6 +43,7 @@ function deferred(): Deferred { async function harness() { const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-'))) + tempDirs.push(root) const ctx = new Context() roots.push(ctx) await ctx.plugin(SessionStore) diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 644acd4892..2a172984d3 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -1,8 +1,8 @@ -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { @@ -13,7 +13,16 @@ import { const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-')) +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const tmp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-app-boot-')) + tempRoots.push(dir) + return dir +} describe('resolveConfigPath', () => { it('resolves relative to the given cwd outside replay mode', () => { diff --git a/packages/boot/app-boot/tests/config-dump.spec.ts b/packages/boot/app-boot/tests/config-dump.spec.ts index ef2c9fc68e..6703df20e0 100644 --- a/packages/boot/app-boot/tests/config-dump.spec.ts +++ b/packages/boot/app-boot/tests/config-dump.spec.ts @@ -7,18 +7,27 @@ * shared overlay whose row exists only on another surface. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches, renderConfigDump } from '../src/index.ts' const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-')) +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const tmp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-config-dump-')) + tempRoots.push(dir) + return dir +} function writeBase(dir: string): string { const base = join(dir, 'base.yml') diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index ca718a65b3..386b537ce4 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -4,10 +4,10 @@ * previous generation has been retained or restored. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Include } from '@deepseek-ai/cordis-plugin-include' import { boot } from '../src/index.ts' @@ -16,6 +16,11 @@ const NAME = 'dsh-test-bin' const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n' +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + interface TreeFixture { ctx: Context dir: string @@ -24,6 +29,7 @@ interface TreeFixture { async function bootTree(configBody: string, files: Record = {}): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) + tempRoots.push(dir) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content) writeFileSync(join(dir, 'cordis.yml'), configBody) @@ -282,6 +288,7 @@ describe('loader tree replacement', () => { describe('include refresh with overlay patches', () => { it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) + tempRoots.push(dir) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') writeFileSync(join(dir, 'cordis.yml'), [ @@ -347,6 +354,7 @@ describe('include patches layered over one base', () => { // must therefore be able to reach a row an earlier layer inserted, or // bundle-only rows would be invisible to the user's patch layer. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) + tempRoots.push(dir) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') writeFileSync(join(dir, 'cordis.yml'), [ diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index 5f52ab5e2a..6487ad0695 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -7,7 +7,10 @@ import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' + +/** Every per-test tree root, removed once the booted watcher has been disposed. */ +const hmrRoots: string[] = [] async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise { const ctx = new Context() @@ -32,6 +35,10 @@ async function eventually(test: () => boolean, message: string): Promise { } describe('HMR exact config paths', () => { + afterEach(() => { + for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true }) + }) + it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => { const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) const alias = `${target}-alias` @@ -85,6 +92,7 @@ describe('HMR exact config paths', () => { it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(dir) const observed: string[] = [] @@ -111,6 +119,7 @@ describe('HMR exact config paths', () => { it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(root) const dir = join(root, 'later') const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(root) @@ -129,6 +138,7 @@ describe('HMR exact config paths', () => { it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') writeFileSync(filename, 'one') const ctx = await bootHmr(dir) @@ -170,6 +180,7 @@ describe('HMR exact config paths', () => { it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(dir) const failure = Promise.withResolvers<{ filename: string; error: Error }>() diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 0681157f62..10d1d41b8a 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -11,7 +11,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { withFileLock } from '@deepseek-ai/dsh-atomic-write' -import { describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it } from 'vitest' import { composeEntries, healProfilesModuleFallback, @@ -26,7 +26,16 @@ import { type Profile, } from '../src/index.ts' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-')) +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const tmp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-profile-')) + tempRoots.push(dir) + return dir +} /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */ function stageInstallation( diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 3efdd22db5..a4f6debf97 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -4,11 +4,11 @@ * a real Loader tree, kept live through transactional HMR. */ -import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' @@ -23,7 +23,16 @@ import { const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-')) +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const tmp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-user-patches-')) + tempRoots.push(dir) + return dir +} async function eventually(test: () => boolean, message: string): Promise { const deadline = Date.now() + 10_000 diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 6f41146c61..687a219a2c 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -4,7 +4,7 @@ * active, then resolves that row's config against its injection-ready context. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { EventEmitter } from 'node:events' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -34,6 +34,9 @@ interface Fixture { const disposers: (() => Promise)[] = [] +/** Fixture tree roots, removed after their booted tree has been disposed. */ +const tempDirs: string[] = [] + const readyApp: AppReady = { onReady(listener) { listener() @@ -59,6 +62,7 @@ function controlledAppReady(): { service: AppReady; commit(): void } { afterEach(async () => { for (const dispose of disposers.splice(0)) await dispose() + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) internals.stdin = process.stdin internals.stdout = process.stdout internals.stderr = process.stderr @@ -103,6 +107,7 @@ async function bootFixture( options: { objectInject?: boolean; withoutProvider?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) + tempDirs.push(dir) const observed: Observed = { exits: [], out: '' } writeFileSync(join(dir, 'reader.mjs'), ` export const name = 'reader' diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 3db8d68bf4..29c5a7666b 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -4,7 +4,7 @@ * the consumer pending. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -24,8 +24,12 @@ interface Observed { const disposers: (() => Promise)[] = [] +/** Fixture tree roots, removed after their booted tree has been disposed. */ +const tempDirs: string[] = [] + afterEach(async () => { for (const dispose of disposers.splice(0)) await dispose() + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) internals.stdout = process.stdout internals.stderr = process.stderr }) @@ -37,6 +41,7 @@ afterEach(async () => { */ async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) + tempDirs.push(dir) const observed: Observed = { exits: [], out: '' } writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n') // Loader imports through Node's resolver, so this fixture delegates to the diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index ba3806232c..a10272f9d3 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -3,7 +3,7 @@ * releases a consumer whose config reads `ctx.webStartup` directly. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -23,8 +23,12 @@ interface Observed { const disposers: (() => Promise)[] = [] +/** Fixture tree roots, removed after their booted tree has been disposed. */ +const tempDirs: string[] = [] + afterEach(async () => { for (const dispose of disposers.splice(0)) await dispose() + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) internals.stdout = process.stdout internals.stderr = process.stderr }) @@ -39,6 +43,7 @@ async function bootProvider(args: string[]): Promise<{ observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) + tempDirs.push(dir) const observed: Observed = { exits: [], out: '' } writeFileSync(join(dir, 'reader.mjs'), ` export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config } diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index d6d154033b..9b97859ac8 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -1,7 +1,7 @@ -import { mkdtemp } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from '../src/file-store.ts' @@ -31,6 +31,12 @@ const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' } const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 } const NOW = 1_700_000_000_000 +/** Every temp store root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + function requestUrl(input: string | URL | Request): string { if (typeof input === 'string') return input return input instanceof URL ? input.href : input.url @@ -64,6 +70,7 @@ function uploadFetch(now: () => number = () => NOW) { describe('DeepSeekFileStore', () => { it('singleflights the first upload and reuses the durable mapping across store instances', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const remote = uploadFetch() const first = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl }) @@ -84,6 +91,7 @@ describe('DeepSeekFileStore', () => { it('keeps a shared upload alive while another waiter remains', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) let complete: ((response: Response) => void) | undefined let uploadSignal: AbortSignal | undefined @@ -123,6 +131,7 @@ describe('DeepSeekFileStore', () => { it('aborts the shared upload after its only waiter cancels', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) let uploadSignal: AbortSignal | undefined const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => { @@ -149,6 +158,7 @@ describe('DeepSeekFileStore', () => { it('normalizes a non-Error cancellation reason', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => ( new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => { @@ -176,6 +186,7 @@ describe('DeepSeekFileStore', () => { it('starts a fresh upload while the cancelled transport is settling', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) let requests = 0 const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => { requests += 1 @@ -222,6 +233,7 @@ describe('DeepSeekFileStore', () => { it('does not persist an upload whose response is missing and retries on the next request', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const good = uploadFetch() let first = true @@ -242,6 +254,7 @@ describe('DeepSeekFileStore', () => { it('rejects an upload response whose byte count differs from the request version', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000, filename: 'dsh-wrong.png', purpose: 'user_data', @@ -262,6 +275,7 @@ describe('DeepSeekFileStore', () => { ['image/gif', 'gif'], ] as const)('uses the %s filename extension for uploads', async (mediaType, extension) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const remote = uploadFetch() const store = new DeepSeekFileStore({ index: new DeepSeekUploadIndex(join(dir, `${extension}.json`)), @@ -279,6 +293,7 @@ describe('DeepSeekFileStore', () => { it('normalizes a non-Error failure from the durable upload index', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) vi.spyOn(index, 'get').mockRejectedValue('index unavailable') const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch }) @@ -291,6 +306,7 @@ describe('DeepSeekFileStore', () => { it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) let now = NOW const remote = uploadFetch(() => now) @@ -311,6 +327,7 @@ describe('DeepSeekFileStore', () => { it('releases an indexed file through DELETE and removes only that mapping', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const remote = uploadFetch() const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl }) @@ -323,6 +340,7 @@ describe('DeepSeekFileStore', () => { it('removes a losing upload and keeps the winning durable mapping when duplicate cleanup fails', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) vi.spyOn(index, 'commit').mockResolvedValue({ accepted: false, @@ -352,6 +370,7 @@ describe('DeepSeekFileStore', () => { it('reclaims one owned file after quota rejection and retries the upload once', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) let uploads = 0 const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { if (init?.method === 'POST') { @@ -394,6 +413,7 @@ describe('DeepSeekFileStore', () => { it('preserves a quota error when no harness-owned file can be reclaimed', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => { if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({ error: { message: 'file count quota exceeded', code: 'file_quota' }, @@ -418,6 +438,7 @@ describe('DeepSeekFileStore', () => { it('finishes pagination before deleting cursor files during quota recovery', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const deleted = new Set() const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const target = new URL(requestUrl(input)) @@ -456,6 +477,7 @@ describe('DeepSeekFileStore', () => { it('stops pagination when a page omits or repeats its cursor', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) for (const mode of ['missing', 'repeated'] as const) { let page = 0 const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { @@ -482,6 +504,7 @@ describe('DeepSeekFileStore', () => { it('releases every batch and clears the scoped upload index', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch }) const reclaim = vi.spyOn(store, 'reclaimOldestOwned') diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts index 2cad8a22be..989a921e69 100644 --- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts +++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts @@ -1,7 +1,7 @@ -import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import { DeepSeekFileId } from '../src/file-id.ts' import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' @@ -9,6 +9,12 @@ import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`) const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`) +/** Every temp index root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + describe('DeepSeekUploadIndex', () => { it('normalizes trailing endpoint slashes in the credential scope', () => { expect(deepSeekFileScope('https://api.deepseek.com///', 'key')) @@ -17,6 +23,7 @@ describe('DeepSeekUploadIndex', () => { it('isolates API-key namespaces and reuses only records above the refresh margin', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const first = deepSeekFileScope('https://api.deepseek.com', 'first-key') const second = deepSeekFileScope('https://api.deepseek.com', 'second-key') @@ -38,6 +45,7 @@ describe('DeepSeekUploadIndex', () => { it('keeps a reusable cross-process winner and removes only an exact generation', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const first = { @@ -56,6 +64,7 @@ describe('DeepSeekUploadIndex', () => { it('treats a corrupt upload cache as empty and repairs it on the next commit', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const path = join(dir, 'index.json') await writeFile(path, '{bad', 'utf8') const index = new DeepSeekUploadIndex(path) @@ -128,6 +137,7 @@ describe('DeepSeekUploadIndex', () => { })}]}`, ])('treats an invalid persisted index as empty %#', async (text) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const path = join(dir, 'index.json') await writeFile(path, text, 'utf8') const index = new DeepSeekUploadIndex(path) @@ -138,6 +148,7 @@ describe('DeepSeekUploadIndex', () => { it('rejects duplicate persisted mappings as a corrupt cache', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const path = join(dir, 'index.json') const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const record = { @@ -151,6 +162,7 @@ describe('DeepSeekUploadIndex', () => { it('drops expired records on commit and clears only the selected namespace', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const first = deepSeekFileScope('https://api.deepseek.com', 'first') const second = deepSeekFileScope('https://api.deepseek.com', 'second') @@ -171,6 +183,7 @@ describe('DeepSeekUploadIndex', () => { it('propagates non-cache filesystem read failures', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const path = join(dir, 'directory') await mkdir(path) const index = new DeepSeekUploadIndex(path) diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index 656f4bdb7f..0723fe93da 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -6,7 +6,7 @@ * stays read-only. */ -import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -15,7 +15,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { COMPOSITION_FILE, copyComposition, METADATA_FILE, type Config, } from '@deepseek-ai/dsh-agent-presets' @@ -23,6 +23,12 @@ import AgentPresets, { const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' +/** Every temp root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + let ctx: Context let userRoot: string @@ -49,6 +55,7 @@ async function seedPreset( beforeEach(async () => { userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')) + roots.push(userRoot) ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -201,6 +208,7 @@ describe('deleting a preset', () => { describe('a deployment with more than one user root', () => { it('refuses to delete a preset the writable root does not own', async () => { const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-')) + roots.push(second) await seedPreset(second, 'elsewhere') const layered = new Context() layered.baseUrl = pathToFileURL(FIXTURES).href + '/' @@ -246,7 +254,9 @@ describe('a deployment with no writable root', () => { describe('a user root that does not exist yet', () => { it('is created by the first copy', async () => { - const absent = join(await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')), 'nested', 'preset') + const absentRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')) + roots.push(absentRoot) + const absent = join(absentRoot, 'nested', 'preset') const fresh = new Context() fresh.baseUrl = pathToFileURL(FIXTURES).href + '/' await fresh.plugin(Loader) diff --git a/packages/preset/agent-presets/tests/composition-inventory.spec.ts b/packages/preset/agent-presets/tests/composition-inventory.spec.ts index fa6cdf596a..74565362e2 100644 --- a/packages/preset/agent-presets/tests/composition-inventory.spec.ts +++ b/packages/preset/agent-presets/tests/composition-inventory.spec.ts @@ -5,7 +5,7 @@ * read reported broken by reason instead of dropped. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -33,6 +33,9 @@ const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n' const contexts: Context[] = [] +/** Every temp root created by this file, removed after its contexts settle. */ +const roots: string[] = [] + /** A Loader-context evaluator over an empty scope, enough for literal gates. */ const evaluateExpression = (expression: string): unknown => evaluate({}, expression) /** An evaluator that refuses every expression, leaving rows conditional. */ @@ -41,6 +44,7 @@ const refuseExpression = (): never => { throw new Error('no loader context') } afterEach(async () => { vi.restoreAllMocks() await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) async function harness(roster: Config): Promise { @@ -63,6 +67,7 @@ async function harness(roster: Config): Promise { describe('fileComposition', () => { it('flattens groups and keeps refused expressions conditional', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + roots.push(dir) const path = join(dir, COMPOSITION_FILE) await writeFile(path, [ '- id: alpha', @@ -124,6 +129,7 @@ describe('fileComposition', () => { it('evaluates decidable gates the way a mount would', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + roots.push(dir) const path = join(dir, COMPOSITION_FILE) await writeFile(path, [ '- id: off', @@ -144,6 +150,7 @@ describe('fileComposition', () => { it('answers broken for a file that stopped reading as a composition', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + roots.push(dir) const missing = await fileComposition(join(dir, COMPOSITION_FILE), refuseExpression) expect(missing).toHaveProperty('broken') @@ -201,6 +208,7 @@ describe('mountedCompositionRows', () => { describe('AgentPresets.compositionInventory', () => { it('reads unmounted presets from their files, marking the default and metadata', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) + roots.push(userRoot) await mkdir(join(userRoot, 'documented')) await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), [ VALID.trimEnd(), @@ -287,6 +295,7 @@ describe('AgentPresets.compositionInventory', () => { it('prefers the standing mount over a file that broke after mounting', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-composition-volatile-')) + roots.push(root) await mkdir(join(root, 'volatile')) const plugin = join(FIXTURES, 'plugins', 'contribute.js') await writeFile( @@ -344,6 +353,7 @@ describe('AgentPresets.compositionInventory', () => { it('keeps a broken preset on the inventory with its discovery reason', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) + roots.push(userRoot) await mkdir(join(userRoot, 'damaged')) const ctx = await harness({ default: 'minimal', diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts index 4ff1843557..3c22525bdf 100644 --- a/packages/preset/agent-presets/tests/discovery.spec.ts +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -1,8 +1,8 @@ -import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' const fsHarness = vi.hoisted(() => ({ @@ -31,6 +31,12 @@ const HARNESS = new URL('.', import.meta.url).href const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } +/** Every temp root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + beforeEach(() => { fsHarness.nextReadError = undefined }) @@ -38,6 +44,7 @@ beforeEach(() => { describe('display order', () => { it('puts declared order first, then everything else by id', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-order-')) + roots.push(root) for (const [id, order] of [['zulu', 1], ['alpha', 2]] as const) { await mkdir(join(root, id), { recursive: true }) await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') @@ -57,6 +64,7 @@ describe('display order', () => { it('breaks a tie between equal declared orders by id', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-')) + roots.push(root) for (const id of ['yankee', 'alpha']) { await mkdir(join(root, id), { recursive: true }) await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') @@ -94,6 +102,7 @@ describe('preset discovery', () => { it('skips a directory whose name no preset id could ever claim', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-')) + roots.push(root) await mkdir(join(root, '.hidden')) await mkdir(join(root, 'Has_Caps')) await mkdir(join(root, 'usable')) @@ -129,6 +138,7 @@ describe('preset discovery', () => { it('ignores a plain file sitting beside the preset directories', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + roots.push(root) await writeFile(join(root, 'stray.yml'), '- id: x\n') await mkdir(join(root, 'real')) await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n') @@ -140,6 +150,7 @@ describe('preset discovery', () => { it('reports a root it cannot read rather than treating it as empty', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + roots.push(root) const notADirectory = join(root, 'file-as-root') await writeFile(notADirectory, 'not a directory\n') @@ -168,6 +179,7 @@ describe('composition health', () => { */ async function scanned(composition: string): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-')) + roots.push(root) await mkdir(join(root, 'probe')) await writeFile(join(root, 'probe', COMPOSITION_FILE), composition) const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS) @@ -208,6 +220,7 @@ describe('composition health', () => { it('reports a composition that stats but cannot be read', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-')) + roots.push(root) await mkdir(join(root, 'sealed')) const path = join(root, 'sealed', COMPOSITION_FILE) await writeFile(path, '[]\n') @@ -235,6 +248,7 @@ describe('rows naming a plugin that cannot be resolved', () => { /** One directory under a fresh root holding `composition`, scanned. */ async function scanned(composition: string): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-resolve-')) + roots.push(root) await mkdir(join(root, 'probe')) await writeFile(join(root, 'probe', COMPOSITION_FILE), composition) const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS) @@ -272,6 +286,7 @@ describe('rows naming a plugin that cannot be resolved', () => { it('resolves a preset-relative row against the preset\'s own directory', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-relative-')) + roots.push(root) await mkdir(join(root, 'probe')) await writeFile(join(root, 'probe', 'own-plugin.mjs'), 'export function apply() {}\n') await writeFile(join(root, 'probe', COMPOSITION_FILE), '- id: own\n name: ./own-plugin.mjs\n- id: gone\n name: ./deleted.mjs\n') @@ -311,6 +326,7 @@ describe('rows naming a plugin that cannot be resolved', () => { // The fast path, and the one that has to answer alone: this package has a // directory and nothing to import, so a resolver would reject it. const home = await mkdtemp(join(tmpdir(), 'dsh-presets-installed-')) + roots.push(home) await mkdir(join(home, 'node_modules', '@scope', 'pkg'), { recursive: true }) await writeFile(join(home, 'node_modules', '@scope', 'pkg', 'package.json'), '{"name":"@scope/pkg"}\n') await mkdir(join(home, 'presets', 'probe'), { recursive: true }) @@ -326,6 +342,7 @@ describe('rows naming a plugin that cannot be resolved', () => { // What a stale profile install leaves behind: the name is still in // `node_modules`, pointing at a checkout that is gone. const home = await mkdtemp(join(tmpdir(), 'dsh-presets-dangling-')) + roots.push(home) await mkdir(join(home, 'node_modules', '@scope'), { recursive: true }) await symlink(join(home, 'deleted-checkout'), join(home, 'node_modules', '@scope', 'pkg')) await mkdir(join(home, 'presets', 'probe'), { recursive: true }) diff --git a/packages/preset/agent-presets/tests/metadata.spec.ts b/packages/preset/agent-presets/tests/metadata.spec.ts index 8bdabe1c59..99ed7b97f8 100644 --- a/packages/preset/agent-presets/tests/metadata.spec.ts +++ b/packages/preset/agent-presets/tests/metadata.spec.ts @@ -6,15 +6,22 @@ * from the file a user can write. */ -import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts' +/** Every temp preset directory created by this file, removed after each test. */ +const tempDirs: string[] = [] +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + /** A preset directory holding exactly the given metadata text. */ async function presetDir(content?: string): Promise { const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-')) + tempDirs.push(dir) await mkdir(dir, { recursive: true }) if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content) return dir diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 9d5d052724..bd78447fff 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -13,7 +13,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' @@ -86,6 +86,13 @@ function rootResolves(ctx: Context, name: string): boolean { } let ctx: Context + +/** Every temp preset root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + beforeEach(async () => { ctx = await harness() }) @@ -93,6 +100,7 @@ beforeEach(async () => { describe('composing an agent from a preset', () => { it('hands an absolute plugin path to Node as a file URL', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-')) + roots.push(root) const presetDir = join(root, 'absolute') const plugin = join(FIXTURES, 'plugins', 'contribute.js') await mkdir(presetDir) @@ -360,6 +368,7 @@ describe('composing from a broken preset', () => { /** A roster whose only user preset carries `composition`. */ async function rosterWith(composition: string): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) + roots.push(root) await mkdir(join(root, 'damaged')) await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) @@ -428,6 +437,7 @@ describe('the preset file is an input, never a persistence target', () => { // committed fixture would be mutated by the very run that proves the bug // and every later run would compare against the damaged file and pass. const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-')) + roots.push(root) const dir = join(root, 'self-disposing') await mkdir(dir) const path = join(dir, COMPOSITION_FILE) @@ -625,6 +635,7 @@ describe('replacing a composition', () => { // A preset root this test owns, so removing the composition mid-flight // cannot disturb the shipped fixtures. const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-')) + roots.push(root) const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', `- id: nope\n name: ${join(FIXTURES, 'plugins', 'throws.js')}\n config:\n message: refuses\n`]] for (const [id, body] of seeded) { await mkdir(join(root, id)) @@ -679,6 +690,7 @@ describe('editing a composition file', () => { */ async function editable(id: string): Promise<{ scoped: Context; path: string }> { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-')) + roots.push(root) await mkdir(join(root, id)) const path = join(root, id, COMPOSITION_FILE) await writeFile(path, rowFor('before')) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index 15b683a986..d04eed3514 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -4,7 +4,7 @@ * which is the only one of the three that mutates an agent. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -34,7 +34,13 @@ const ROOTS = [ // temp preset directory these tests seed would report the composition broken. const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n' -afterEach(() => vi.restoreAllMocks()) +/** Every temp preset root created by this file, removed after each test. */ +const roots: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) async function remoteFailure(operation: Promise): Promise { try { @@ -97,6 +103,7 @@ const recordedPreset = (agent: Agent): unknown => describe('the roster a client reads', () => { it('projects path-free rows, marking the default and carrying published metadata', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) await mkdir(join(userRoot, 'documented'), { recursive: true }) await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID) await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n') @@ -122,6 +129,7 @@ describe('the roster a client reads', () => { it('keeps a broken preset on the roster with its reason', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) await mkdir(join(userRoot, 'damaged'), { recursive: true }) const ctx = await harness({ default: 'standard', @@ -174,6 +182,7 @@ describe('reading one composition', () => { it('carries the display metadata a preset published', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) await mkdir(join(userRoot, 'documented'), { recursive: true }) await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID) await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n') @@ -241,6 +250,7 @@ describe('authoring over Remote', () => { it('copies and deletes through the Remote adapters', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) const ctx = await harness({ default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }], @@ -432,6 +442,7 @@ describe('switching one session\'s composition', () => { it('reports an unusable composition with its discovery reason', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) await mkdir(join(userRoot, 'damaged'), { recursive: true }) const ctx = await harness({ default: 'standard', diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 9445eb604e..887c288aa5 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -4,7 +4,7 @@ * so a person can change which preset new sessions get without a restart. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -19,13 +19,19 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }] const NS = SETTINGS_NAMESPACE +/** Every temp root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + /** * A composition with a real file-backed settings provider. `settingsFiber` is * the provider's own handle, so a test can take it away the way a reload does. @@ -34,6 +40,7 @@ async function harness( extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [], ): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> { const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-')) + roots.push(home) const settingsFile = join(home, 'settings.yaml') await writeFile(settingsFile, '{}\n') @@ -119,6 +126,7 @@ describe('the default preset as a user setting', () => { it('clears a user default it has just deleted', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-')) + roots.push(root) await mkdir(join(root, 'mine')) await writeFile( join(root, 'mine', COMPOSITION_FILE), diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts index 323c56ee12..6b6dcd1789 100644 --- a/packages/preset/agent-presets/tests/shipped-root.spec.ts +++ b/packages/preset/agent-presets/tests/shipped-root.spec.ts @@ -9,7 +9,7 @@ * suite: the derived writable root is resolved in the constructor. */ -import { mkdtemp, readFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -24,16 +24,19 @@ import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const SYSTEM_ROOT = join(FIXTURES, 'system') +let home: string let previousHome: string | undefined beforeEach(async () => { previousHome = process.env.DSH_HOME - process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-')) + home = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-')) + process.env.DSH_HOME = home }) -afterEach(() => { +afterEach(async () => { if (previousHome === undefined) delete process.env.DSH_HOME else process.env.DSH_HOME = previousHome + await rm(home, { recursive: true, force: true }) }) /** Boot a roster with the shipped root left to the plugin's default. */ diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts index 6da05b2ca7..f8eaf15a6d 100644 --- a/packages/preset/agent-presets/tests/user-root.spec.ts +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -10,7 +10,7 @@ * temporary home, or it would reach the developer's real one. */ -import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -31,15 +31,20 @@ const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\ let home: string let previousHome: string | undefined +/** Extra per-test roots, removed with the home directory. */ +const explicitRoots: string[] = [] + beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-')) previousHome = process.env.DSH_HOME process.env.DSH_HOME = home }) -afterEach(() => { +afterEach(async () => { if (previousHome === undefined) delete process.env.DSH_HOME else process.env.DSH_HOME = previousHome + await rm(home, { recursive: true, force: true }) + for (const root of explicitRoots.splice(0)) await rm(root, { recursive: true, force: true }) }) /** Boot a roster over the fixture system root, with the derived root left to the plugin. */ @@ -120,6 +125,7 @@ describe('the harness-home preset root', () => { it('yields to a configured user root for authoring, which writableRoot takes first', async () => { const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-')) + explicitRoots.push(explicit) const ctx = await roster({ roots: [ { path: SYSTEM_ROOT, trust: 'system' as const }, diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 285afb865b..3cfcdde6e3 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -7,10 +7,10 @@ * are all exercised through the real `confine()` path. */ -import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' @@ -24,6 +24,12 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../s const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } +/** Every temp dir created by this file (fake launchers and runner entries), removed after each test. */ +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) { const ctx = new Context() await ctx.plugin(LocalSandboxProvider, config) @@ -39,12 +45,15 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter * `sandbox-windows-acl/lib/runner.js`. */ function absentRunnerEntry(): string { - return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js') + const dir = mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')) + tempDirs.push(dir) + return join(dir, 'runner.js') } /** Write an executable fake `landlock-run` that answers `--probe` with `report`. */ function fakeLauncher(report = 'landlock: fully enforced'): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) + tempDirs.push(dir) const launcher = join(dir, 'landlock-run') writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 }) return launcher @@ -53,6 +62,7 @@ function fakeLauncher(report = 'landlock: fully enforced'): string { /** Write an executable fake `sandbox-exec` that exits `status` for any invocation. */ function fakeSeatbeltExec(status: number): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-seatbelt-')) + tempDirs.push(dir) const exec = join(dir, 'sandbox-exec') writeFileSync(exec, `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 }) return exec @@ -316,6 +326,7 @@ describe('the default landlock probe (launcher CLI contract)', () => { it('reads a failing launcher as unusable: the chain ends and fails closed', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) + tempDirs.push(dir) const launcher = join(dir, 'landlock-run') writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 }) const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }) @@ -337,6 +348,7 @@ describe('probeTimeoutMs config', () => { // spawnSync blocks the worker and fork/exec latency inflates wall-clock) // cannot flip either verdict; the vitest timeout clears the patient budget. const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-')) + tempDirs.push(dir) const launcher = join(dir, 'landlock-run') writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 }) @@ -440,6 +452,7 @@ describe('the windows-acl probe (runner invocation contract)', () => { it('prefers the built lib/runner.js entry when the resolved file exists', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-')) + tempDirs.push(dir) const builtEntry = join(dir, 'runner.js') writeFileSync(builtEntry, '') const { sandbox } = await setup({}, { diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts index 49fdc03816..1541363b95 100644 --- a/packages/sandbox/sandbox/tests/roots.spec.ts +++ b/packages/sandbox/sandbox/tests/roots.spec.ts @@ -4,16 +4,22 @@ * deriving from `writableRoots` — cannot drift. */ -import { realpathSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { mkdtempSync } from 'node:fs' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' +/** Every temp root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + describe('canonicalPath', () => { it('resolves symlinks (an existing path realpaths)', () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-')) + roots.push(dir) expect(canonicalPath(dir)).toBe(realpathSync.native(dir)) }) @@ -29,11 +35,12 @@ describe('writableRoots', () => { it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => { const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-')) - const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws }) - expect(roots).toContain(realpathSync.native(ws)) - expect(roots).toContain(canonicalPath('/tmp')) - expect(roots).toContain(realpathSync.native(tmpdir())) + roots.push(ws) + const writable = writableRoots({ mode: 'workspace-write', workspaceRoot: ws }) + expect(writable).toContain(realpathSync.native(ws)) + expect(writable).toContain(canonicalPath('/tmp')) + expect(writable).toContain(realpathSync.native(tmpdir())) // Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide). - expect(new Set(roots).size).toBe(roots.length) + expect(new Set(writable).size).toBe(writable.length) }) }) diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index c081ad7078..0f5b7992f3 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -1,7 +1,7 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -10,6 +10,10 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + async function setup(config: ConstructorParameters[1] = {}) { const ctx = new Context() await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 53047a62c8..319c588ac5 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -9,11 +9,11 @@ * writes CRLF on Windows, so exact text assertions normalize line endings. */ -import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' -import { describe, expect, it } from 'vitest' +import { afterAll, afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -24,6 +24,16 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + +/** Per-test temp dirs, removed after each test. */ +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + // The probe follows the executor's own resolution (Program Files installs on // Windows are found even when bare `pwsh` is not on PATH). const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 @@ -114,6 +124,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => it('returns the first EXISTING win32 candidate, else pwsh', () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-')) + tempDirs.push(dir) const store = join(dir, 'store') mkdirSync(store, { recursive: true }) writeFileSync(join(store, 'pwsh.exe'), '') @@ -131,6 +142,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => // Store app execution aliases stat as EACCES but lstat as a link; a // dangling symlink reproduces that split on every platform. const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-')) + tempDirs.push(dir) const store = join(dir, 'store') mkdirSync(store, { recursive: true }) const link = join(store, 'pwsh.exe') @@ -141,6 +153,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => it('skips a directory candidate and falls through to the PATH-resolution default', () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-')) + tempDirs.push(dir) const store = join(dir, 'store') mkdirSync(join(store, 'pwsh.exe'), { recursive: true }) expect(resolvePwshPath(undefined, { @@ -201,6 +214,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { it('uses config cwd, overridable per call', async () => { const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-')) const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-')) + tempDirs.push(first, second) const { bash } = await setup({ cwd: first }) const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true) diff --git a/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts b/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts index 95a0883a33..db57f3f6f5 100644 --- a/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts +++ b/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts @@ -3,7 +3,7 @@ import type { Stats } from 'node:fs' import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SkillRegistry from '@deepseek-ai/dsh-skill' @@ -91,8 +91,16 @@ vi.mock('chokidar', () => ({ const SkillFileSystem = await import('../src/index.ts') +/** Every temp dir created by this file, removed after each test. */ +const tempDirs: string[] = [] +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + async function tempDir(name: string): Promise { - return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + tempDirs.push(dir) + return dir } async function writeSkill(root: string, name: string): Promise { diff --git a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts index 238d1421c7..054535d7e7 100644 --- a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts +++ b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' @@ -7,8 +7,16 @@ import SkillRegistry from '@deepseek-ai/dsh-skill' import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' import * as SkillFileSystem from '../src/index.ts' +/** Every temp dir created by this file, removed after each test. */ +const tempDirs: string[] = [] +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + async function tempDir(name: string): Promise { - return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + tempDirs.push(dir) + return dir } async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise { diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 858367134a..f51f1db132 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { mkdir, writeFile } from 'node:fs/promises' +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from '@deepseek-ai/cordis' @@ -15,8 +15,16 @@ import * as toolSkill from '@deepseek-ai/dsh-tool-skill' const testToolSignal = new AbortController().signal +/** Every temp dir created by this file, removed after each test. */ +const tempDirs: string[] = [] +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + async function tempDir(name: string): Promise { - return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + tempDirs.push(dir) + return dir } async function writeSkill(root: string, name: string, description: string, body: string): Promise { diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts index ba518cf035..e3ebd4b07a 100644 --- a/packages/spill/spill-local/src/store.ts +++ b/packages/spill/spill-local/src/store.ts @@ -7,7 +7,7 @@ */ import { createHash, randomBytes } from 'node:crypto' -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { mkdir, open } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -38,6 +38,13 @@ export function privateRoot(): string { return defaultRoot } +// The spill root is private to this process, so it is removed when the process +// ends normally; a process killed with SIGKILL cannot run this, and its +// residue is left to OS temp hygiene. +process.once('exit', () => { + if (defaultRoot !== undefined) rmSync(defaultRoot, { recursive: true, force: true }) +}) + // Spill keeps its empty-name policy local so storage backends stay decoupled. /* jscpd:ignore-start */ /** diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 433ba01791..058dd32f79 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -10,7 +10,7 @@ import { type ChildProcess, spawn, spawnSync } from 'node:child_process' import type { Readable } from 'node:stream' import { randomBytes } from 'node:crypto' -import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' +import { closeSync, mkdtempSync, openSync, rmSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' @@ -91,6 +91,13 @@ function privateSpillDir(): string { return defaultSpillDir } +// The spill directory is private to this process, so it is removed when the +// process ends normally; a process killed with SIGKILL cannot run this, and +// its residue is left to OS temp hygiene. +process.once('exit', () => { + if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true }) +}) + /** * Collects one stream with a bounded in-memory tail. With a spill cap, on * first overflow a spill file is created and every chunk (including those diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 1f21dfaaa1..a0743fec4b 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -1,7 +1,7 @@ -import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { childEnv, killGroup, @@ -82,6 +82,10 @@ vi.mock('node:fs', async (importOriginal) => { const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + type SpecOverrides = Partial[0]> & { stdoutMaxBytes?: number stderrMaxBytes?: number diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts index 6a1774cff4..e30ccf915a 100644 --- a/scripts/coverage-partitions.spec.ts +++ b/scripts/coverage-partitions.spec.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises' +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -22,7 +22,12 @@ import { const passed: CoverageCommandResult = { exitCode: 0, signalCode: null } -afterEach(() => vi.restoreAllMocks()) +/** Every temporary root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) async function writeBlob(command: CoverageCommand): Promise { if (command.blobPath === undefined) return @@ -31,7 +36,9 @@ async function writeBlob(command: CoverageCommand): Promise { } async function temporaryRoot(): Promise { - return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-')) + const root = await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-')) + roots.push(root) + return root } /** Write a Vitest results cache under a temporary root. */ From 1107ff5fe13110e1663ce349083162cad2687087 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 3 Sep 2026 16:26:22 +0800 Subject: [PATCH 05/12] test: remove remaining dsh-* spill/temp dirs in shell and fs specs Full-template residue histogram on the CI host surfaced four more normal-exit leaks below the earlier cutoff: tool-bash/tool-pwsh tools.spec (module spill dir / per-test homes), bash-sandbox sandbox.spec (module spill dir and the read-only denial root), and fs-sandbox fs-sandbox.spec (inline tmp dir). All four now remove what they create at the same teardown points as their neighbors. --- .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 11 ++++++--- .../shell/bash-sandbox/tests/sandbox.spec.ts | 23 +++++++++++++------ packages/shell/tool-bash/tests/tools.spec.ts | 8 +++++-- packages/shell/tool-pwsh/tests/tools.spec.ts | 12 ++++++++-- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 435086a301..95ae891808 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -100,9 +100,14 @@ describe('workspace-write containment', () => { }) it('a write to the platform temp area lands (parity with the bash runner grant)', async () => { - const path = join(await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')), 'temp.txt') - await fs.writeText(await target(path), 'temp') - expect(await readFile(path, 'utf8')).toBe('temp') + const dir = await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')) + try { + const path = join(dir, 'temp.txt') + await fs.writeText(await target(path), 'temp') + expect(await readFile(path, 'utf8')).toBe('temp') + } finally { + await rm(dir, { recursive: true, force: true }) + } }) it('an absolute path outside the workspace is denied, no file created', async () => { diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 4029cc4f40..21d2dfceaa 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -8,7 +8,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -23,6 +23,10 @@ import type { Config } from '@deepseek-ai/dsh-bash-sandbox' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + /** One recorded provider call: the argv handed over and the policy it rode with. */ interface ConfineCall { argv: string[] @@ -518,12 +522,17 @@ describe('result facts', () => { it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => { const { bash } = await setup() - const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked') - mkdirSync(lockedDir) - chmodSync(lockedDir, 0o555) - const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` })) - expect(result.exitCode).not.toBe(0) - expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + const deniedRoot = mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')) + try { + const lockedDir = join(deniedRoot, 'locked') + mkdirSync(lockedDir) + chmodSync(lockedDir, 0o555) + const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` })) + expect(result.exitCode).not.toBe(0) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + } finally { + rmSync(deniedRoot, { recursive: true, force: true }) + } }) it('carries the provider\'s partial-enforcement fact through unchanged', async () => { diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 747d98b48d..6da7972087 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -1,7 +1,7 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { ShellExecutor } from '@deepseek-ai/dsh-shell' @@ -29,6 +29,10 @@ const testToolSignal = new AbortController().signal const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + /** Foreground-only harness: no job runtime (backgrounding fails loud here). */ async function setup() { const ctx = new Context() diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 5e27e3c8e0..26194bd9c2 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -10,9 +10,9 @@ * is pinned separately in integration.spec.ts. */ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { mkdtempSync, realpathSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -38,6 +38,12 @@ import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts' const testToolSignal = new AbortController().signal +/** Per-test temp dirs (session cwd/home fixtures), removed after each test. */ +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + /** * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()` * returns the armed foreground script, `start()` returns the armed background @@ -388,6 +394,7 @@ describe('argument validation', () => { describe('execution through the bash seam', () => { it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => { const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-')) + tempDirs.push(dshHome) const { ctx, bash } = await setup({}, dshHome) bash.handler = () => runResult('hi\n') const agent = registerFakeAgent(ctx, 'session-1') @@ -538,6 +545,7 @@ describe('per-call sandbox policy resolution', () => { it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => { const { ctx, bash } = await setupSandboxed() const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-')) + tempDirs.push(sessionCwd) const agent = registerFakeAgent(ctx, 'policy-session') Object.assign(agent.session.header, { cwd: sessionCwd }) const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent) From 6d9776a3b0cd387d63756d100c278ef313e9d371 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 3 Sep 2026 16:35:45 +0800 Subject: [PATCH 06/12] test: exempt process-exit spill cleanup from the per-file coverage gate The exit listener runs after the coverage dump, so its body can never be measured by the unit coverage lane; mark it v8-ignore with the reason, as with other process-exit-only code paths. --- packages/spill/spill-local/src/store.ts | 1 + packages/subprocess/subprocess-local/src/spawn.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts index e3ebd4b07a..1dc679798b 100644 --- a/packages/spill/spill-local/src/store.ts +++ b/packages/spill/spill-local/src/store.ts @@ -41,6 +41,7 @@ export function privateRoot(): string { // The spill root is private to this process, so it is removed when the process // ends normally; a process killed with SIGKILL cannot run this, and its // residue is left to OS temp hygiene. +/* v8 ignore next 3 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */ process.once('exit', () => { if (defaultRoot !== undefined) rmSync(defaultRoot, { recursive: true, force: true }) }) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 058dd32f79..a06eb7f0d1 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -94,6 +94,7 @@ function privateSpillDir(): string { // The spill directory is private to this process, so it is removed when the // process ends normally; a process killed with SIGKILL cannot run this, and // its residue is left to OS temp hygiene. +/* v8 ignore next 3 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */ process.once('exit', () => { if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true }) }) From 29e6669e3cd16d5905d05fe6a0fef13a38ba79e8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 3 Sep 2026 17:06:48 +0800 Subject: [PATCH 07/12] test: narrow process-exit spill cleanup to empty dirs; drop spill-store deletion Review (ds-review-bot) found the exit-time deletion of per-process spill roots conflicts with the documented retention decision: spill artifacts are model-visible locators that resumed or forked sessions may still reference (2026-07-17-local-spill-startup-cleanup), and an exception thrown from an 'exit' listener can change the process exit code. - dsh-spill-local: revert the exit handler entirely; its default root is already owned by the package's 30-day startup sweep. - dsh-subprocess-local/spawn: remove the per-process spill dir at a JavaScript-observable exit only while it is EMPTY (collectors unlink their spill files on dispose), best-effort with a named swallow so a Windows-held handle never changes the exit code; dirs holding spill files keep them for external cleanup. - Document the empty-dir exit removal in the package README and the Agent Note, and re-record both bilingual pairs. --- ...6-08-28-test-temp-dir-self-cleanup.i18n.yaml | 4 ++-- .../2026-08-28-test-temp-dir-self-cleanup.md | 6 +++--- .../2026-08-28-test-temp-dir-self-cleanup.zh.md | 6 +++--- packages/spill/spill-local/src/store.ts | 10 +--------- .../subprocess-local/README.i18n.yaml | 4 ++-- packages/subprocess/subprocess-local/README.md | 4 ++-- .../subprocess/subprocess-local/README.zh.md | 4 ++-- .../subprocess/subprocess-local/src/spawn.ts | 17 +++++++++++------ 8 files changed, 26 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml index a8eaf5c011..b809d76982 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.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/process/2026-08-28-test-temp-dir-self-cleanup.md -2026-08-28-test-temp-dir-self-cleanup.md: e8e0c5e2db716ff9a63ef55ae1af07a66eaf28a0 -2026-08-28-test-temp-dir-self-cleanup.zh.md: cdc0dfa2b6a00fc11703743c51326238bf644fb1 +2026-08-28-test-temp-dir-self-cleanup.md: a9190878628d1c7bc7be87ff6c90297256a5a32f +2026-08-28-test-temp-dir-self-cleanup.zh.md: 1b7b80d3e6eeaa66b2ea8e49722bc7e7d88433da diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md index e8e0c5e2db..a919087862 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md @@ -15,7 +15,7 @@ Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning te - Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in `afterEach`/`afterAll` (`rm`/`rmSync` with `recursive: true, force: true`), the convention already used across the session packages. Root-creating helpers (`tmp()`, `tempDir()`, `fakeLauncher()`, harness functions) register the root at creation, so every caller is covered at one point. - Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in `afterAll` after the last test. - The file list came from the observed-residue inventory on the CI host (a template histogram of current `/tmp/dsh-*` dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged. -- Product per-process spill roots (`privateSpillDir` in `dsh-subprocess-local/spawn`, `privateRoot` in `dsh-spill-local/store`) register a `process.once('exit')` handler that removes the memoized directory, so every process that used the spawn/spill path cleans up on normal exit. +- Product cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit while it is EMPTY — collectors unlink their spill files on dispose, so a normal exit leaves at most empty residue, and a directory still holding spill files keeps them (their content may outlive the process). The removal is best-effort (a Windows-held handle must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the retention decision in the 2026-07-17 local-spill-startup-cleanup note forbids deleting fresh spill artifacts that resumed or forked sessions may still reference. ## Verification @@ -34,6 +34,6 @@ Not chosen: the files that leak each create roots through their own small helper ## Consequences -- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown, and per-process spill roots are removed when their process exits normally. +- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown; the empty per-process spill directory of `dsh-subprocess-local` is removed at a JavaScript-observable process exit. - Cost: a process killed with SIGKILL (a cancelled run, a timeout kill) cannot run any in-process teardown; its in-flight residue remains. The machine-side timer stays as the backstop for that path. -- Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned per-process roots are covered by the exit handler in the process that created them. +- Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned spill dirs holding spill files, and `dsh-spill-local`'s default root, keep their files per the existing retention policy and are cleaned by that package's own sweep. diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md index cdc0dfa2b6..1b7b80d3e6 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md @@ -15,7 +15,7 @@ Status: implemented - 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在 `afterEach`/`afterAll` 里删除(`rm`/`rmSync` 带 `recursive: true, force: true`)——与 session 包既有的 `roots.splice(0)` 约定一致。创建 root 的 helper(`tmp()`、`tempDir()`、`fakeLauncher()`、harness 函数)在创建处登记,一个点覆盖全部调用方。 - 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的 `afterAll` 里删除。 - 目标文件清单来自 CI 主机上的残留实测清单(当前 `/tmp/dsh-*` 目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。 -- 产品侧每进程 spill root(`dsh-subprocess-local/spawn` 的 `privateSpillDir`、`dsh-spill-local/store` 的 `privateRoot`)注册 `process.once('exit')` handler,在进程正常退出时删除记忆化的目录——凡走过 spawn/spill 路径的进程都会在正常结束时清理。 +- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时仅当目录**为空**才删除——collector 在 dispose 时删除自己的 spill 文件,因此正常退出最多留下空目录残留;仍持有 spill 文件的目录会保留(其内容可能比进程更长命)。删除是 best-effort(Windows 上被占用的句柄不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且 2026-07-17 local-spill-startup-cleanup note 的保留策略禁止删除 resume/fork 会话仍可能引用的新 spill 产物。 ## Verification @@ -34,6 +34,6 @@ Sweep 步骤与 timer 只删已存在的残留;本地运行仍会累积,机 ## Consequences -- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;每进程 spill root 在其进程正常退出时删除。 +- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;`dsh-subprocess-local` 的空每进程 spill 目录在 JavaScript 可观察的进程退出时删除。 - 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。 -- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有每进程 root 由创建它的进程的 exit handler 覆盖。 +- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有、仍持有 spill 文件的目录与 `dsh-spill-local` 的默认 root 按既有保留策略保留文件,由该包自身的 sweep 清理。 diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts index 1dc679798b..ba518cf035 100644 --- a/packages/spill/spill-local/src/store.ts +++ b/packages/spill/spill-local/src/store.ts @@ -7,7 +7,7 @@ */ import { createHash, randomBytes } from 'node:crypto' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync } from 'node:fs' import { mkdir, open } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -38,14 +38,6 @@ export function privateRoot(): string { return defaultRoot } -// The spill root is private to this process, so it is removed when the process -// ends normally; a process killed with SIGKILL cannot run this, and its -// residue is left to OS temp hygiene. -/* v8 ignore next 3 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */ -process.once('exit', () => { - if (defaultRoot !== undefined) rmSync(defaultRoot, { recursive: true, force: true }) -}) - // Spill keeps its empty-name policy local so storage backends stay decoupled. /* jscpd:ignore-start */ /** diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 76150e77d5..f9ae3a73fa 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: dd9edbc99578f5411fad993cf88f93429dc9cab2 -README.zh.md: 7bb43f7bbce5ef6b128edc9ef2104d35a5887845 +README.md: 28b61eeec972b3bcd80c91824eaa972d4d4e469b +README.zh.md: fce2bf42fdfe8a751b104eb01cad824dfe41cd37 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index dd9edbc995..28b61eeec9 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -50,7 +50,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina ### Shutdown behavior -Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. +Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. The same exit removes the private per-process spill directory when it is empty; a directory still holding spill files keeps them (their content outlives the process) until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. ### What can go wrong @@ -127,7 +127,7 @@ These limits define when the provider is a poor fit or needs special operational - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the PTY root; on Linux, a `setsid` child leaves both the tree and the owned terminal session; the provider adds no continuous process-table monitor. - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event; an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`, `SIGKILL`, fatal OOM, `process.abort()`, native crashes, and power loss require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill directory) accumulate under the OS tmpdir until something external cleans them. +- **Completed spill files are not deleted** — bounded full-output recovery files accumulate under the OS tmpdir until something external cleans them; the private per-process spill directory is removed at a JavaScript-observable exit once it is empty. ### Dev Note diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 7bb43f7bbc..fce2bf42fd 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 关闭行为 -正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。 +正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。同一退出阶段会删除每进程私有 spill 目录(仅当其为空时);仍持有 spill 文件的目录会保留这些文件(其内容比进程更长命),直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。 ### 可能出错的地方 @@ -127,7 +127,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 - **守护化的终端后代仍可能逃出可观察边界**——在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 PTY 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话;本提供方不新增持续进程表监视器。 - **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件;未处理的 `SIGTERM`、`SIGINT` 或 `SIGHUP`、`SIGKILL`、fatal OOM、`process.abort()`、native crash 与断电,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。 - **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 -- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件(以及每进程私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理。 +- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件会在 OS tmpdir 下累积,直到外部机制进行清理;每进程私有 spill 目录在 JavaScript 可观察的退出阶段一旦为空即被删除。 ### 开发备注 diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index a06eb7f0d1..6359aa98e0 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -10,7 +10,7 @@ import { type ChildProcess, spawn, spawnSync } from 'node:child_process' import type { Readable } from 'node:stream' import { randomBytes } from 'node:crypto' -import { closeSync, mkdtempSync, openSync, rmSync, unlinkSync, writeSync } from 'node:fs' +import { closeSync, mkdtempSync, openSync, readdirSync, rmdirSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' @@ -91,12 +91,17 @@ function privateSpillDir(): string { return defaultSpillDir } -// The spill directory is private to this process, so it is removed when the -// process ends normally; a process killed with SIGKILL cannot run this, and -// its residue is left to OS temp hygiene. -/* v8 ignore next 3 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */ +// The per-process spill directory is removed at process exit only while it is +// EMPTY: collectors unlink their spill files on dispose, so a normal exit +// leaves at most empty residue. A directory still holding spill files keeps +// them (their content outlives the process), and a SIGKILLed process cannot +// run this at all; both are left to OS temp hygiene. +/* v8 ignore next 3 -- exit listeners run after the coverage dump; empty-only removal is verified by the CI /tmp residue measurement. */ process.once('exit', () => { - if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true }) + if (defaultSpillDir === undefined) return + try { + if (readdirSync(defaultSpillDir).length === 0) rmdirSync(defaultSpillDir) + } catch { /* best-effort: a Windows-held handle must not change the exit code. */ } }) /** From 1bc330ce01d2f3eb778d69aea4250e913dec35b9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 3 Sep 2026 17:07:43 +0800 Subject: [PATCH 08/12] docs: cross-link the spill retention decision from the teardown note The Agent Note now links the 2026-07-17 local-spill-startup-cleanup note it builds on, and the privateSpillDir JSDoc documents the empty-dir exit removal alongside the directory's creation contract. --- .../process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml | 4 ++-- .../process/2026-08-28-test-temp-dir-self-cleanup.md | 2 +- .../process/2026-08-28-test-temp-dir-self-cleanup.zh.md | 2 +- packages/subprocess/subprocess-local/src/spawn.ts | 5 ++++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml index b809d76982..11992e0ef1 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.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/process/2026-08-28-test-temp-dir-self-cleanup.md -2026-08-28-test-temp-dir-self-cleanup.md: a9190878628d1c7bc7be87ff6c90297256a5a32f -2026-08-28-test-temp-dir-self-cleanup.zh.md: 1b7b80d3e6eeaa66b2ea8e49722bc7e7d88433da +2026-08-28-test-temp-dir-self-cleanup.md: d8c7d6ef3efd0538adfc3b6d0269119f61861e08 +2026-08-28-test-temp-dir-self-cleanup.zh.md: 1e811dd820d2e5ede62ae7d4fb523e317d2372e7 diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md index a919087862..d8c7d6ef3e 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md @@ -15,7 +15,7 @@ Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning te - Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in `afterEach`/`afterAll` (`rm`/`rmSync` with `recursive: true, force: true`), the convention already used across the session packages. Root-creating helpers (`tmp()`, `tempDir()`, `fakeLauncher()`, harness functions) register the root at creation, so every caller is covered at one point. - Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in `afterAll` after the last test. - The file list came from the observed-residue inventory on the CI host (a template histogram of current `/tmp/dsh-*` dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged. -- Product cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit while it is EMPTY — collectors unlink their spill files on dispose, so a normal exit leaves at most empty residue, and a directory still holding spill files keeps them (their content may outlive the process). The removal is best-effort (a Windows-held handle must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the retention decision in the 2026-07-17 local-spill-startup-cleanup note forbids deleting fresh spill artifacts that resumed or forked sessions may still reference. +- Product cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit while it is EMPTY — collectors unlink their spill files on dispose, so a normal exit leaves at most empty residue, and a directory still holding spill files keeps them (their content may outlive the process). The removal is best-effort (a Windows-held handle must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the [retention decision](../architecture/2026-07-17-local-spill-startup-cleanup.md) forbids deleting fresh spill artifacts that resumed or forked sessions may still reference. ## Verification diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md index 1b7b80d3e6..1e811dd820 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md @@ -15,7 +15,7 @@ Status: implemented - 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在 `afterEach`/`afterAll` 里删除(`rm`/`rmSync` 带 `recursive: true, force: true`)——与 session 包既有的 `roots.splice(0)` 约定一致。创建 root 的 helper(`tmp()`、`tempDir()`、`fakeLauncher()`、harness 函数)在创建处登记,一个点覆盖全部调用方。 - 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的 `afterAll` 里删除。 - 目标文件清单来自 CI 主机上的残留实测清单(当前 `/tmp/dsh-*` 目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。 -- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时仅当目录**为空**才删除——collector 在 dispose 时删除自己的 spill 文件,因此正常退出最多留下空目录残留;仍持有 spill 文件的目录会保留(其内容可能比进程更长命)。删除是 best-effort(Windows 上被占用的句柄不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且 2026-07-17 local-spill-startup-cleanup note 的保留策略禁止删除 resume/fork 会话仍可能引用的新 spill 产物。 +- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时仅当目录**为空**才删除——collector 在 dispose 时删除自己的 spill 文件,因此正常退出最多留下空目录残留;仍持有 spill 文件的目录会保留(其内容可能比进程更长命)。删除是 best-effort(Windows 上被占用的句柄不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且[保留策略 note](../architecture/2026-07-17-local-spill-startup-cleanup.zh.md)禁止删除 resume/fork 会话仍可能引用的新 spill 产物。 ## Verification diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 6359aa98e0..faaf0e4d1f 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -84,7 +84,10 @@ let defaultSpillDir: string | undefined /** * The default spill location: a private (0700) per-process directory under * the OS tmpdir, created lazily. Predictable world-readable paths would let - * other local users read command output or pre-create symlinks. + * other local users read command output or pre-create symlinks. At a + * JavaScript-observable process exit the directory is removed while empty + * (collectors unlink their spill files on dispose); a directory still holding + * spill files keeps them for external cleanup. */ function privateSpillDir(): string { defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-')) From c8fc38541f13cf1283eeba49634b40b803917a82 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 3 Sep 2026 17:29:21 +0800 Subject: [PATCH 09/12] test: own the default spill dir in subprocess specs; correct retention wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round (ds-review-bot v4p/v5/v6): the exit cleanup claim that collectors unlink their spill files on dispose was wrong — completed spill files are retained (seal() only closes, discardSpill() is the only unlink), so the exit removal only ever applies to directories that never spilled. Reword the JSDoc, README, and Agent Note accordingly, drop the redundant readdirSync precheck (rmdirSync ENOTEMPTY is caught anyway), and recount the v8-ignore window. The spawn.spec default-spill test now records the default per-process directory it created and removes it in afterAll, so a completed spill file is never left behind by the unit suite. --- ...08-28-test-temp-dir-self-cleanup.i18n.yaml | 4 ++-- .../2026-08-28-test-temp-dir-self-cleanup.md | 6 ++--- ...026-08-28-test-temp-dir-self-cleanup.zh.md | 6 ++--- .../subprocess-local/README.i18n.yaml | 4 ++-- .../subprocess/subprocess-local/README.md | 4 ++-- .../subprocess/subprocess-local/README.zh.md | 4 ++-- .../subprocess/subprocess-local/src/spawn.ts | 24 +++++++++---------- .../subprocess-local/tests/spawn.spec.ts | 5 ++++ 8 files changed, 30 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml index 11992e0ef1..4ac5e89a89 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.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/process/2026-08-28-test-temp-dir-self-cleanup.md -2026-08-28-test-temp-dir-self-cleanup.md: d8c7d6ef3efd0538adfc3b6d0269119f61861e08 -2026-08-28-test-temp-dir-self-cleanup.zh.md: 1e811dd820d2e5ede62ae7d4fb523e317d2372e7 +2026-08-28-test-temp-dir-self-cleanup.md: 9d10227e431cd2e8a771f3807f0933ccb084ec4c +2026-08-28-test-temp-dir-self-cleanup.zh.md: 01cd53b6f191fe0f43d6579557b3c89fcc408a95 diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md index d8c7d6ef3e..9d10227e43 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md @@ -15,11 +15,11 @@ Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning te - Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in `afterEach`/`afterAll` (`rm`/`rmSync` with `recursive: true, force: true`), the convention already used across the session packages. Root-creating helpers (`tmp()`, `tempDir()`, `fakeLauncher()`, harness functions) register the root at creation, so every caller is covered at one point. - Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in `afterAll` after the last test. - The file list came from the observed-residue inventory on the CI host (a template histogram of current `/tmp/dsh-*` dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged. -- Product cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit while it is EMPTY — collectors unlink their spill files on dispose, so a normal exit leaves at most empty residue, and a directory still holding spill files keeps them (their content may outlive the process). The removal is best-effort (a Windows-held handle must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the [retention decision](../architecture/2026-07-17-local-spill-startup-cleanup.md) forbids deleting fresh spill artifacts that resumed or forked sessions may still reference. +- Product cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit when it holds no completed spill file — completed spill files are retained as full-output recovery artifacts until an external cleanup, so only directories that never spilled (the dominant residue shape on the CI host: 92% of sampled `dsh-subprocess-*` dirs are empty) are removed. The removal is best-effort (ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the [retention decision](../architecture/2026-07-17-local-spill-startup-cleanup.md) forbids deleting fresh spill artifacts that resumed or forked sessions may still reference. ## Verification -- Targeted local run of every changed spec (32 files, 700 tests) passed, including the suites that exercise the changed product sources. +- Targeted local runs of every changed unit spec passed (the 35 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane. - CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g. `dsh-profile-`, `dsh-app-boot-`, `dsh-presets-*`, `dsh-upload-index-`) should no longer appear in fresh `/tmp` residue on the CI host. ## Alternatives considered @@ -34,6 +34,6 @@ Not chosen: the files that leak each create roots through their own small helper ## Consequences -- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown; the empty per-process spill directory of `dsh-subprocess-local` is removed at a JavaScript-observable process exit. +- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown; a `dsh-subprocess-local` per-process spill directory holding no completed spill file is removed at a JavaScript-observable process exit. - Cost: a process killed with SIGKILL (a cancelled run, a timeout kill) cannot run any in-process teardown; its in-flight residue remains. The machine-side timer stays as the backstop for that path. - Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned spill dirs holding spill files, and `dsh-spill-local`'s default root, keep their files per the existing retention policy and are cleaned by that package's own sweep. diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md index 1e811dd820..01cd53b6f1 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md @@ -15,11 +15,11 @@ Status: implemented - 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在 `afterEach`/`afterAll` 里删除(`rm`/`rmSync` 带 `recursive: true, force: true`)——与 session 包既有的 `roots.splice(0)` 约定一致。创建 root 的 helper(`tmp()`、`tempDir()`、`fakeLauncher()`、harness 函数)在创建处登记,一个点覆盖全部调用方。 - 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的 `afterAll` 里删除。 - 目标文件清单来自 CI 主机上的残留实测清单(当前 `/tmp/dsh-*` 目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。 -- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时仅当目录**为空**才删除——collector 在 dispose 时删除自己的 spill 文件,因此正常退出最多留下空目录残留;仍持有 spill 文件的目录会保留(其内容可能比进程更长命)。删除是 best-effort(Windows 上被占用的句柄不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且[保留策略 note](../architecture/2026-07-17-local-spill-startup-cleanup.zh.md)禁止删除 resume/fork 会话仍可能引用的新 spill 产物。 +- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时,仅当目录**未持有任何已完成的 spill 文件**才删除——已完成的 spill 文件作为完整输出恢复产物保留到外部清理,因此只有从未 spill 过的目录(CI 主机残留的主流形态:抽样 `dsh-subprocess-*` 目录 92% 为空)会被删除。删除是 best-effort(ENOENT/ENOTEMPTY/EBUSY/EPERM 不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且[保留策略 note](../architecture/2026-07-17-local-spill-startup-cleanup.zh.md)禁止删除 resume/fork 会话仍可能引用的新 spill 产物。 ## Verification -- 本地定向跑过全部改动 spec(32 个文件、700 个测试)通过,含直接使用改动后产品源码的套件。 +- 本地定向跑过全部改动单测 spec 通过(35 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。 - CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如 `dsh-profile-`、`dsh-app-boot-`、`dsh-presets-*`、`dsh-upload-index-`)应不再出现在 CI 主机的新鲜 `/tmp` 残留里。 ## Alternatives considered @@ -34,6 +34,6 @@ Sweep 步骤与 timer 只删已存在的残留;本地运行仍会累积,机 ## Consequences -- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;`dsh-subprocess-local` 的空每进程 spill 目录在 JavaScript 可观察的进程退出时删除。 +- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;`dsh-subprocess-local` 未持有任何已完成 spill 文件的每进程 spill 目录在 JavaScript 可观察的进程退出时删除。 - 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。 - 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有、仍持有 spill 文件的目录与 `dsh-spill-local` 的默认 root 按既有保留策略保留文件,由该包自身的 sweep 清理。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index f9ae3a73fa..0c214c83c1 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: 28b61eeec972b3bcd80c91824eaa972d4d4e469b -README.zh.md: fce2bf42fdfe8a751b104eb01cad824dfe41cd37 +README.md: 3727be3ba274838ee5e483652a7d359b57491715 +README.zh.md: d5a8ae4fcabd5a1e0c3ef10c2c9ae0a85180c5de diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 28b61eeec9..3727be3ba2 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -50,7 +50,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina ### Shutdown behavior -Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. The same exit removes the private per-process spill directory when it is empty; a directory still holding spill files keeps them (their content outlives the process) until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. +Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. The same exit removes the private per-process spill directory when it holds no completed spill file (completed spill files are retained as full-output recovery artifacts until an external cleanup). Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. ### What can go wrong @@ -127,7 +127,7 @@ These limits define when the provider is a poor fit or needs special operational - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the PTY root; on Linux, a `setsid` child leaves both the tree and the owned terminal session; the provider adds no continuous process-table monitor. - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event; an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`, `SIGKILL`, fatal OOM, `process.abort()`, native crashes, and power loss require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files accumulate under the OS tmpdir until something external cleans them; the private per-process spill directory is removed at a JavaScript-observable exit once it is empty. +- **Completed spill files are not deleted** — bounded full-output recovery files accumulate under the OS tmpdir until something external cleans them; the private per-process spill directory is removed at a JavaScript-observable exit only when it holds no completed spill file. ### Dev Note diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index fce2bf42fd..d5a8ae4fca 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 关闭行为 -正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。同一退出阶段会删除每进程私有 spill 目录(仅当其为空时);仍持有 spill 文件的目录会保留这些文件(其内容比进程更长命),直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。 +正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。同一退出阶段会删除每进程私有 spill 目录(仅当其未持有任何已完成的 spill 文件时;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理)。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。 ### 可能出错的地方 @@ -127,7 +127,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 - **守护化的终端后代仍可能逃出可观察边界**——在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 PTY 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话;本提供方不新增持续进程表监视器。 - **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件;未处理的 `SIGTERM`、`SIGINT` 或 `SIGHUP`、`SIGKILL`、fatal OOM、`process.abort()`、native crash 与断电,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。 - **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 -- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件会在 OS tmpdir 下累积,直到外部机制进行清理;每进程私有 spill 目录在 JavaScript 可观察的退出阶段一旦为空即被删除。 +- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件会在 OS tmpdir 下累积,直到外部机制进行清理;每进程私有 spill 目录仅在未持有任何已完成 spill 文件时于 JavaScript 可观察的退出阶段删除。 ### 开发备注 diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index faaf0e4d1f..5fe81d1879 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -10,7 +10,7 @@ import { type ChildProcess, spawn, spawnSync } from 'node:child_process' import type { Readable } from 'node:stream' import { randomBytes } from 'node:crypto' -import { closeSync, mkdtempSync, openSync, readdirSync, rmdirSync, unlinkSync, writeSync } from 'node:fs' +import { closeSync, mkdtempSync, openSync, rmdirSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' @@ -85,26 +85,24 @@ let defaultSpillDir: string | undefined * The default spill location: a private (0700) per-process directory under * the OS tmpdir, created lazily. Predictable world-readable paths would let * other local users read command output or pre-create symlinks. At a - * JavaScript-observable process exit the directory is removed while empty - * (collectors unlink their spill files on dispose); a directory still holding - * spill files keeps them for external cleanup. + * JavaScript-observable process exit the directory is removed only when it + * holds no completed spill file (spill files are retained as full-output + * recovery artifacts until an external cleanup). */ function privateSpillDir(): string { defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-')) return defaultSpillDir } -// The per-process spill directory is removed at process exit only while it is -// EMPTY: collectors unlink their spill files on dispose, so a normal exit -// leaves at most empty residue. A directory still holding spill files keeps -// them (their content outlives the process), and a SIGKILLed process cannot -// run this at all; both are left to OS temp hygiene. -/* v8 ignore next 3 -- exit listeners run after the coverage dump; empty-only removal is verified by the CI /tmp residue measurement. */ +// The per-process spill directory is removed at process exit when it holds no +// completed spill file: a directory that never spilled is empty and is safe to +// remove, while a directory holding completed spill files keeps them (their +// content is retained until an external cleanup). A SIGKILLed process cannot +// run this at all; its residue is left to OS temp hygiene. +/* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */ process.once('exit', () => { if (defaultSpillDir === undefined) return - try { - if (readdirSync(defaultSpillDir).length === 0) rmdirSync(defaultSpillDir) - } catch { /* best-effort: a Windows-held handle must not change the exit code. */ } + try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ } }) /** diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index a0743fec4b..5775b20b17 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -82,8 +82,12 @@ vi.mock('node:fs', async (importOriginal) => { const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-')) +/** The per-process default spill dir captured by the default-spill test. */ +let defaultSpillDir: string | undefined + afterAll(() => { rmSync(spillDir, { recursive: true, force: true }) + if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true }) }) type SpecOverrides = Partial[0]> & { @@ -1090,6 +1094,7 @@ describe('environment and spill-file hardening', () => { spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), )) const dir = dirname(result.stdout.spillPath!) + defaultSpillDir = dir expect(dir).toMatch(/dsh-subprocess-/) const mode = statSync(dir).mode & 0o777 expect(mode).toBe(0o700) From cf4cfd9e4a7b3ef9386ea8a75974e282dc243dad Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 3 Sep 2026 18:17:09 +0800 Subject: [PATCH 10/12] docs: correct the changed-spec count in the Agent Note verification section --- .../process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml | 4 ++-- .../process/2026-08-28-test-temp-dir-self-cleanup.md | 2 +- .../process/2026-08-28-test-temp-dir-self-cleanup.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml index 4ac5e89a89..f3b2f73ea5 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.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/process/2026-08-28-test-temp-dir-self-cleanup.md -2026-08-28-test-temp-dir-self-cleanup.md: 9d10227e431cd2e8a771f3807f0933ccb084ec4c -2026-08-28-test-temp-dir-self-cleanup.zh.md: 01cd53b6f191fe0f43d6579557b3c89fcc408a95 +2026-08-28-test-temp-dir-self-cleanup.md: 57c3af8441fdc1e3ee107f2121d247bf72acb9d8 +2026-08-28-test-temp-dir-self-cleanup.zh.md: db987803cf96c33d53ee7c21b1b14e56129c17bb diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md index 9d10227e43..57c3af8441 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md @@ -19,7 +19,7 @@ Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning te ## Verification -- Targeted local runs of every changed unit spec passed (the 35 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane. +- Targeted local runs of every changed unit spec passed (the 37 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane. - CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g. `dsh-profile-`, `dsh-app-boot-`, `dsh-presets-*`, `dsh-upload-index-`) should no longer appear in fresh `/tmp` residue on the CI host. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md index 01cd53b6f1..db987803cf 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md @@ -19,7 +19,7 @@ Status: implemented ## Verification -- 本地定向跑过全部改动单测 spec 通过(35 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。 +- 本地定向跑过全部改动单测 spec 通过(37 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。 - CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如 `dsh-profile-`、`dsh-app-boot-`、`dsh-presets-*`、`dsh-upload-index-`)应不再出现在 CI 主机的新鲜 `/tmp` 残留里。 ## Alternatives considered From d18770f50a8cb096bf21a01d7b5e00417eee65d9 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 3 Sep 2026 21:19:09 +0800 Subject: [PATCH 11/12] fix(web): address latest session reveal review --- ...-03-session-search-result-reveal.i18n.yaml | 4 +-- ...2026-09-03-session-search-result-reveal.md | 10 +++--- ...6-09-03-session-search-result-reveal.zh.md | 10 +++--- .agents/notes/archived/manifest.json | 6 ++-- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +-- ...2026-07-27-trajectory-inspection-ledger.md | 2 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 2 +- ...kspace-sidebar-order-and-folding.i18n.yaml | 4 +-- ...-11-workspace-sidebar-order-and-folding.md | 4 +-- ...-workspace-sidebar-order-and-folding.zh.md | 4 +-- .../tests/trajectory-virtualization.e2e.ts | 3 +- .../client/ui-trajectory/README.i18n.yaml | 4 +-- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../src/client/TrajectoryTable.tsx | 4 +-- .../src/client/rows/WorkspaceBrowser.tsx | 5 +-- .../tests/workspace-browser.client.spec.tsx | 33 +++++++++++++++++++ 17 files changed, 70 insertions(+), 33 deletions(-) diff --git a/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml index 3ec848b61f..a4882f82cb 100644 --- a/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.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/bug-fix/2026-09-03-session-search-result-reveal.md -2026-09-03-session-search-result-reveal.md: 71511ba84f6e33eece8c64c4a20a8ea5eac7ceb9 -2026-09-03-session-search-result-reveal.zh.md: aeea1b6b31f61771579ad0061eae858d23dcdcd8 +2026-09-03-session-search-result-reveal.md: dc43d5e06febf61852d4b8dcdd0b5ca290f623e0 +2026-09-03-session-search-result-reveal.zh.md: 0982498f5602ee18b20368d2b0dc04ab1ffae7f0 diff --git a/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md index 71511ba84f..dc43d5e06f 100644 --- a/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md +++ b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md @@ -11,9 +11,9 @@ Selecting a Session search result opened its conversation while leaving the side ## Decision -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) treats result selection as a transition back to normal browsing. It opens the owning Workspace or Ungrouped group, records the target Session id, clears the query, collapses search, and opens the Session. When grouped browsing remounts, the target group starts with its hidden remainder transiently revealed. Flat browsing needs no fold override. +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) treats result selection as a transition back to normal browsing. It records the target Session id, clears the query, collapses search, and opens the Session. In grouped browsing, `SessionTree` waits until the current Workspace stream has a complete Host baseline, derives and opens the owning Workspace or Ungrouped group, and transiently reveals the hidden remainder only when the target is behind the five-row fold. Flat browsing needs no fold override. -The normal Session row owns completion of the one-shot reveal. A matching mounted row scrolls itself into the nearest visible position and acknowledges the target id, preventing later renders from repeating the scroll. Metadata and content matches use the same transition because both result kinds resolve to a Session id. +The normal Session row owns completion of the one-shot reveal. A matching mounted row scrolls itself into the nearest visible position and acknowledges the target id, preventing later renders from repeating the scroll. Starting another non-empty search cancels an unacknowledged reveal. Metadata and content matches use the same transition because both result kinds resolve to a Session id. ## Alternatives considered @@ -27,8 +27,10 @@ The normal Session row owns completion of the one-shot reveal. A matching mounte ## Consequences -Selecting a result discards the current query and returns the sidebar to normal browsing. The owning group stays open, and its hidden remainder is visible for that tree mount, so the selected row supplies both hierarchy context and an on-screen location. A later search or ordinary render does not repeat the scroll after acknowledgment. +Selecting a result discards the current query and returns the sidebar to normal browsing. The owning group stays open, and its hidden remainder is visible for that tree mount when required, so the selected row supplies both hierarchy context and an on-screen location. Waiting for the current Workspace baseline prevents a reconnect's retained membership from acknowledging the reveal before replacement state arrives. A later search or ordinary render does not repeat the scroll after acknowledgment. + +The target row is the only completion signal. If another client archives or moves the Session between result selection and row mount, the reveal remains armed; the row will scroll if it mounts later, unless a new non-empty search cancels the reveal or the browser unmounts. ## Testing -UI tests cover a content-only hit in the sixth position of a closed Workspace, the corresponding transient group expansion and scroll acknowledgment, and the same one-shot scroll in flat mode. The assembled Web navigation test verifies that one click clears search and leaves exactly one selected Session row in the normal tree. The long-conversation browser test opens its seeded Session with that single-click transition. +UI tests cover a content-only hit in the sixth position of a closed Workspace, pending and reconnecting Workspace baselines, cancellation by a new search, transient group expansion and scroll acknowledgment, and the same one-shot scroll in flat mode. The assembled Web navigation test verifies that one click clears search and leaves exactly one selected Session row in the normal tree. The long-conversation browser test opens its seeded Session with that single-click transition. diff --git a/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md index aeea1b6b31..0982498f56 100644 --- a/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md +++ b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md @@ -11,9 +11,9 @@ Archived: 2026-09-03 ## 决策 -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 将结果选择视为返回常规浏览的状态切换。它会打开所属 Workspace 或 Ungrouped 分组、记录目标 Session id、清空查询、收起搜索并打开 Session。分组浏览重新挂载时,目标分组会以临时显露其余隐藏条目的状态启动。单列表浏览不需要覆盖折叠状态。 +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 将结果选择视为返回常规浏览的状态切换。它会记录目标 Session id、清空查询、收起搜索并打开 Session。在分组浏览中,`SessionTree` 会等待当前 Workspace stream 取得完整 Host 基线,再解析并打开所属 Workspace 或 Ungrouped 分组;仅当目标位于五行折叠之后时,才会临时显露其余隐藏条目。单列表浏览不需要覆盖折叠状态。 -常规 Session 行负责完成一次性显露。匹配的行挂载后会将自身滚动到最近的可见位置,再确认目标 id 已处理,避免后续渲染重复滚动。元数据命中与内容命中都会解析为 Session id,因此共用同一套状态切换。 +常规 Session 行负责完成一次性显露。匹配的行挂载后会将自身滚动到最近的可见位置,再确认目标 id 已处理,避免后续渲染重复滚动。开始另一项非空搜索会取消尚未确认的显露。元数据命中与内容命中都会解析为 Session id,因此共用同一套状态切换。 ## 考虑过的替代方案 @@ -27,8 +27,10 @@ Archived: 2026-09-03 ## 后果 -选择结果会丢弃当前查询,并让侧边栏返回常规浏览。所属分组保持打开,其隐藏条目在该次树挂载期间可见,因此选中行同时提供层级上下文与屏幕内位置。确认完成后,后续搜索或普通渲染不会重复滚动。 +选择结果会丢弃当前查询,并让侧边栏返回常规浏览。所属分组保持打开,并在需要时于该次树挂载期间显示隐藏条目,因此选中行同时提供层级上下文与屏幕内位置。等待当前 Workspace 基线可避免重连期间保留的旧归属信息在替换状态到达前确认显露。确认完成后,后续搜索或普通渲染不会重复滚动。 + +目标行是唯一的完成信号。如果另一客户端在结果选择与行挂载之间归档或移动了 Session,显露会保持待处理;除非新的非空搜索取消显露或浏览器卸载,否则该行以后挂载时仍会触发滚动。 ## 测试 -UI 测试覆盖关闭 Workspace 中位于第六位的纯内容命中、对应的临时分组展开与滚动确认,以及单列表模式下的同类一次性滚动。组装层 Web 导航测试验证一次点击会清空搜索,并在常规树中只留下一个选中的 Session 行。长对话浏览器测试通过这次单击状态切换打开预置 Session。 +UI 测试覆盖关闭 Workspace 中位于第六位的纯内容命中、待定与重连中的 Workspace 基线、新搜索取消显露、临时分组展开与滚动确认,以及单列表模式下的同类一次性滚动。组装层 Web 导航测试验证一次点击会清空搜索,并在常规树中只留下一个选中的 Session 行。长对话浏览器测试通过这次单击状态切换打开预置 Session。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 8a28d94099..4d955acd4e 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -139,9 +139,9 @@ "bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d", "bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23", "bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16", - "bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:36fea5421f6f9a3e20a32db8283db7f532433267c1d17ed49b0ec8dbd1fc1765", - "bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:1c80c4cea1193ecdfccd17efc5702255af17ea68c3662b40bced7c70558bf231", - "bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:bf0ff3d9bc262ec83d9930d10da839fe9d1c47b483dc9406e6d065876f9e0cdc", + "bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5", + "bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9", + "bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 346c742905..e17531633a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 7f97bc395ed694a0a750d3b744cc9887834ef32f -2026-07-27-trajectory-inspection-ledger.zh.md: 6a38147a075480882db5c39a0810404654c6c849 +2026-07-27-trajectory-inspection-ledger.md: 75f8480d3e52c6cc1f2eefd7ef9d5a2c03b40dee +2026-07-27-trajectory-inspection-ledger.zh.md: 82af4904d90a6fbfc811d176dd4b859b26d4774c diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 7f97bc395e..75f8480d3e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -23,7 +23,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. - A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes. While an older Session prefix remains unloaded, an interactive first row precedes the loaded records and requests one older page; the same row becomes a disabled loading status for a pending page and disappears only when paging completes. - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. -- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. +- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. The virtualizer's stable-key `followOnAppend` behavior owns structural appends while the user remains at the bottom; the component writes the tail position itself only for a non-virtual ledger. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. - Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window. - Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger. - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 6a38147a07..82af4904d9 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -23,7 +23,7 @@ Status: implemented - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。 - 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前,明确的加载行会遮住真实记录。更早的 Session 前缀仍未加载时,交互式首行位于已加载记录之前,可请求一页更早历史;页面加载期间,同一行会变为禁用的加载状态,仅在分页完成时消失。 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 -- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 +- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。用户保持在底部时,虚拟化器基于稳定键的 `followOnAppend` 行为负责结构性追加;组件只对非虚拟记录表自行写入末尾位置。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 - token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。 - 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml index 125a027c08..a55f1f953f 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.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-11-workspace-sidebar-order-and-folding.md -2026-08-11-workspace-sidebar-order-and-folding.md: 99b0dcaf978411345868a2359c537b677ff771f0 -2026-08-11-workspace-sidebar-order-and-folding.zh.md: b8381e28a62859bdc91aa037e79a276761f9132d +2026-08-11-workspace-sidebar-order-and-folding.md: a8026936905d42ccf5f30a02684310324d8ee359 +2026-08-11-workspace-sidebar-order-and-folding.zh.md: 83a66c9b4a5f8dd34b56c6d8a8af2d18f77e983b diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md index 99b0dcaf97..a802693690 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -20,7 +20,7 @@ The client installs a Workspace drag optimistically. Request and frame generatio ### Session folding and view order -Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. Selecting a Session search result opens its owning group after Workspace membership is ready and reveals the hidden remainder for the current mount only when the target is behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts. +Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. Selecting a Session search result waits for the current Workspace stream's complete Host baseline before opening its owning group, and reveals the hidden remainder for the current mount only when the target is behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts. The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot. @@ -56,4 +56,4 @@ Search is a header action while collapsed and expands across the title and trail ## Testing -Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, search-result reveal beyond the fold, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session. +Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, search-result reveal beyond the fold, initial and reconnecting Workspace-baseline waits, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session. diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md index b8381e28a6..83a66c9b4a 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -20,7 +20,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins ### Session 折叠与视图顺序 -每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。选择 Session 搜索结果会等 Workspace 归属信息就绪后打开所属分组,并且仅当目标位于五行折叠之后时,才会在当前挂载期间显露其余隐藏条目。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 +每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。选择 Session 搜索结果会等待当前 Workspace stream 取得完整 Host 基线后再打开所属分组,并且仅当目标位于五行折叠之后时,才会在当前挂载期间显露其余隐藏条目。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。 @@ -56,4 +56,4 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行 ## 测试 -领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、折叠范围外的搜索结果显露、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。 +领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、折叠范围外的搜索结果显露、初始与重连 Workspace 基线等待、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。 diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index c073e1a3aa..23a692002c 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -34,7 +34,6 @@ const FIXTURE = createChatScrollFixture({ turns: 88, }) const MAX_MOUNTED_ROWS = 160 -const MAX_STREAM_SCROLL_CALLS = 8 const GEOMETRY_TOLERANCE = 2 const STREAM_MARKER = 'TRAJECTORY_VIRTUAL_STREAM_FINISHED' const STREAM_TEXT = Array.from( @@ -343,7 +342,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { return (window as Window & { __trajectoryScrollCalls?: number }) .__trajectoryScrollCalls ?? 0 }) - expect(streamingScrollCalls).toBeLessThanOrEqual(MAX_STREAM_SCROLL_CALLS) + expect(streamingScrollCalls).toBeLessThanOrEqual(5) expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) expect({ pageErrors: tripwire.pageErrors, diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index edc665438f..93b134a4ee 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: d947787c123fca77dc7978456f4cfdc4e99951bf -README.zh.md: 3b281c6c1e90108d66f050e07a3b08208ed8c802 +README.md: d61a100842f9457579dc014b94f541e9809e5a2d +README.zh.md: abc778af44203550c1fb8a9343ad9735ba2a84a2 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index d947787c12..d61a100842 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -47,7 +47,7 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re ### Virtual rows -Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. +Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. The virtualizer owns bottom following for structural appends; non-virtual ledgers use a direct tail-position write. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. ### Layout diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 3b281c6c1e..abc778af44 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -47,7 +47,7 @@ kind: "package-reference" ### 虚拟行 -长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。 +长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。虚拟化器负责结构性追加后的底部跟随;非虚拟记录表会直接写入末尾位置。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。 ### 布局 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 81a428aa35..c7c592e235 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1909,6 +1909,7 @@ export function TrajectoryTable({ overscan: VIRTUAL_OVERSCAN_ROWS, scrollMargin: virtualScrollMargin, scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX, + followOnAppend: 'auto', }) const virtualIndexByRecordId = useMemo(() => { const indexes = new Map() @@ -2284,8 +2285,7 @@ export function TrajectoryTable({ return } if (!followsTableTail.current) return - if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' }) - else pane.scrollTop = pane.scrollHeight + if (!virtualizationEnabled) pane.scrollTop = pane.scrollHeight }, [ historyLoading, historyStartSeq, diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 45cece1bba..0ef21ddb82 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -240,7 +240,7 @@ type SessionTreeProps = Pick< /** Host account home for POSIX hover-path abbreviation. */ home?: string | undefined workspaces: readonly WorkspaceView[] - /** Whether Workspace membership has received its first complete Host baseline. */ + /** Whether the current Workspace stream has a complete Host baseline. */ workspaceReady: boolean /** Explicit persisted zero-or-five-session state by Workspace group. */ groupExpansion: Readonly> @@ -859,6 +859,7 @@ export function WorkspaceBrowser({ const home = useHostInfo(info => info.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) + const workspaceStreamState = useWorkspaces(state => state.state) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) // Live occupancy of this surface's directory-flow hole (the same source the // flow reads): a composition without a picking affordance can add nothing. @@ -1286,7 +1287,7 @@ export function WorkspaceBrowser({ onSessionArchive={onSessionArchive} forkSession={forkSession} workspaces={workspaces} - workspaceReady={workspacePhase === 'ready'} + workspaceReady={workspacePhase === 'ready' && workspaceStreamState !== 'loading'} groupExpansion={groupExpansion} setGroupExpanded={actions.setGroupExpanded} sessionOrderByAccount={sessionOrderByAccount} diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 7c6562a0c6..99437341f3 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -755,6 +755,39 @@ describe('WorkspaceBrowser', () => { expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() }) + it('waits for the reconnect baseline before resolving reveal membership', async () => { + const sessions = sessionState([ + summary('newest-1', 6), + summary('newest-2', 5), + summary('newest-3', 4), + summary('newest-4', 3), + summary('newest-5', 2), + summary('target', 1, { displayTitle: 'Needle session' }), + ]) + const reconnecting = { + ...workspaceState([workspace('stale', [...sessions.ids])]), + state: 'loading' as const, + } + const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(reconnecting) }) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(b.store.getSnapshot().groupExpansion).toEqual({}) + expect(scrollIntoView).not.toHaveBeenCalled() + + rerender(b, { + useWorkspaces: hook(workspaceState([workspace('current', [...sessions.ids])])), + }) + await waitFor(() => { + expect(b.store.getSnapshot().groupExpansion).toEqual({ current: true }) + expect(screen.getByText('Needle session')).toBeTruthy() + }) + const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]') + expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow) + expect(b.store.getSnapshot().groupExpansion).not.toHaveProperty('stale') + }) + it('keeps the bounded group projection when the revealed result is already within it', () => { const sessions = sessionState([ summary('target', 6, { displayTitle: 'Needle session' }), From d8a995e00eb8ed5a63253f1a14d107df2e832c07 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 4 Sep 2026 11:56:31 +0800 Subject: [PATCH 12/12] docs: align the changed-spec count with the merged-base diff (36) --- .../process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml | 4 ++-- .../process/2026-08-28-test-temp-dir-self-cleanup.md | 2 +- .../process/2026-08-28-test-temp-dir-self-cleanup.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml index f3b2f73ea5..cb40bd0029 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.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/process/2026-08-28-test-temp-dir-self-cleanup.md -2026-08-28-test-temp-dir-self-cleanup.md: 57c3af8441fdc1e3ee107f2121d247bf72acb9d8 -2026-08-28-test-temp-dir-self-cleanup.zh.md: db987803cf96c33d53ee7c21b1b14e56129c17bb +2026-08-28-test-temp-dir-self-cleanup.md: 175fe82b0ad01c2afda3abb5f66ff455045a3cd6 +2026-08-28-test-temp-dir-self-cleanup.zh.md: c2fcd07d1619d1556ac99b336f27b89a5f46e426 diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md index 57c3af8441..175fe82b0a 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md @@ -19,7 +19,7 @@ Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning te ## Verification -- Targeted local runs of every changed unit spec passed (the 37 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane. +- Targeted local runs of every changed unit spec passed (the 36 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane. - CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g. `dsh-profile-`, `dsh-app-boot-`, `dsh-presets-*`, `dsh-upload-index-`) should no longer appear in fresh `/tmp` residue on the CI host. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md index db987803cf..c2fcd07d16 100644 --- a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md @@ -19,7 +19,7 @@ Status: implemented ## Verification -- 本地定向跑过全部改动单测 spec 通过(37 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。 +- 本地定向跑过全部改动单测 spec 通过(36 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。 - CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如 `dsh-profile-`、`dsh-app-boot-`、`dsh-presets-*`、`dsh-upload-index-`)应不再出现在 CI 主机的新鲜 `/tmp` 残留里。 ## Alternatives considered