From 891e9035e7e792a8c13bb91fd3f4fab342b87600 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 12:15:06 +0800 Subject: [PATCH 01/33] feat(web): add basic past-session search (round 1) --- .../2026-07-27-web-session-search.i18n.yaml | 6 + .../feature/2026-07-27-web-session-search.md | 42 ++++ .../2026-07-27-web-session-search.zh.md | 42 ++++ apps/cli/README.i18n.yaml | 6 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 7 + apps/cli/package.json | 2 + apps/web/tests/navigation-panes.e2e.ts | 62 ++--- apps/web/tests/scaffold.ts | 1 + .../search-results.expected.md | 2 + packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 126 +++++++++- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 9 +- .../client/connection/tests/fixture.spec.ts | 35 +++ packages/client/runtime/README.i18n.yaml | 6 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/sessions/manager.ts | 29 ++- .../runtime/src/client/sessions/service.ts | 20 +- packages/client/runtime/tests/fake-api.ts | 9 +- packages/client/runtime/tests/manager.spec.ts | 43 ++++ .../runtime/tests/sessions-service.spec.ts | 23 ++ packages/client/ui-workspace/README.i18n.yaml | 6 +- packages/client/ui-workspace/README.md | 7 +- packages/client/ui-workspace/README.zh.md | 7 +- .../src/client/WorkspaceBrowser.module.css | 16 ++ .../src/client/WorkspaceBrowser.tsx | 159 ++++++++++-- .../ui-workspace/src/client/contract/slots.ts | 12 +- .../client/ui-workspace/src/client/index.ts | 6 + .../src/client/rows/Rows.module.css | 58 +++++ .../ui-workspace/src/client/rows/Rows.tsx | 37 ++- .../client/ui-workspace/src/client/tree.ts | 184 ++++++++------ .../client/ui-workspace/tests/apply.spec.ts | 37 ++- .../client/ui-workspace/tests/rows.spec.tsx | 23 +- .../client/ui-workspace/tests/tree.spec.ts | 172 ++++++++----- .../tests/workspace-browser.spec.tsx | 201 ++++++++++++--- packages/host/apiproxy/README.i18n.yaml | 6 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 96 ++++++- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 25 +- packages/host/apiproxy/src/api/sessions.ts | 17 ++ packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 5 +- .../apiproxy/tests/api-proxy-search.spec.ts | 237 ++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 25 ++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 47 ++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 24 +- packages/host/apiproxy/tsconfig.json | 3 + pnpm-lock.yaml | 9 + 56 files changed, 1646 insertions(+), 269 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-session-search.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md create mode 100644 apps/web/tests/snapshots/navigation-panes/search-results.expected.md create mode 100644 packages/host/apiproxy/tests/api-proxy-search.spec.ts 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 new file mode 100644 index 0000000000..31d6b377af --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-search.md +2026-07-27-web-session-search.md: 3cc44ba3652415e9fa32ce67bceefc822c41059b +2026-07-27-web-session-search.zh.md: 2b6ea7a60e1b051757852ff331c0b93c48bd2b57 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 new file mode 100644 index 0000000000..3cc44ba365 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -0,0 +1,42 @@ +# Agent Note: Web past-session search + +Status: implemented + +English | [中文](2026-07-27-web-session-search.zh.md) + +## Problem + +The Web sidebar exposes session titles and Workspace membership but cannot retrieve a past conversation from words that appear only inside its messages. Scanning histories in the browser would require attaching or loading every session, duplicate the existing indexed-search service, and make cold persisted sessions both slow and easy to omit. The product also needs a predictable failure path: an unavailable derived index must not erase title matches that the client can compute locally. + +## Decision + +The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at `.sessions/session-query.db`. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence. + +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`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work. A missing query service or an 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. 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. + +Content matching inherits the SQLite backend's normalized literal token/phrase semantics. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching. + +## Failure and visibility contract + +Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and the query receives only ids from that baseline. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits. + +While the first or a later content request is pending, the UI keeps immediate metadata matches and shows a history-search status. If the backend fails, the same rows remain and a warning explains that content search is unavailable. Zero merged rows produce an explicit empty state. More than 20 candidate rows produce a refine-query hint. + +## Alternatives considered + +- **Scan every session history in the browser** — rejected because it attaches transport and fold cost to the UI, misses cold logs unless they are loaded, and duplicates the semantic extraction and source reconciliation already owned by `ctx.sessionQuery`. +- **Make trigram or fuzzy search part of the first release** — rejected because it changes index size, ranking, short-query behavior, and product expectations. Trigrams also do not by themselves solve two-character queries. The first release uses the existing backend contract and leaves recall expansion as a separate measured decision. +- **Return event addresses and jump to the exact match** — rejected for this release because conversation virtualization and stable event navigation need a separate UI contract. Session-level navigation is useful without coupling search to that work. +- **Expose cursor pagination in the sidebar** — rejected in favor of a fixed top-20 surface and a narrow-query hint; this keeps the interaction and cancellation state bounded. + +## Consequences + +Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search. + +The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. + +## Testing + +Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. 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 new file mode 100644 index 0000000000..2b6ea7a60e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -0,0 +1,42 @@ +# Agent Note: Web 历史会话搜索 + +Status: implemented + +[English](2026-07-27-web-session-search.md) | 中文 + +## 问题 + +Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只出现在消息中的词语检索历史对话。在浏览器中扫描历史记录,需要附加或加载每个会话,重复实现现有的索引搜索服务,也会让冷态持久化会话的检索既缓慢又容易遗漏。产品还需要一条可预测的故障路径:派生索引不可用时,不得抹去客户端能够在本地计算出的标题匹配结果。 + +## 决策 + +Web 与 headless 共用的组合会在 `.sessions/session-query.db` 挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。 + +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 + +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 + +内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。 + +## 故障与可见性契约 + +搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;查询只会接收这条基线提供的 id。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。 + +首个或后续内容请求仍在处理期间,UI 会保留即时元数据匹配结果,并显示历史搜索状态。如果后端失败,这些行会保持不变,并显示警告说明内容搜索不可用。合并后没有任何行时,界面会显示明确的空状态。候选行超过 20 条时,界面会提示用户缩小查询范围。 + +## 曾考虑的替代方案 + +- **在浏览器中扫描每个会话的历史记录**:不予采纳,因为这会让 UI 承担传输与折叠开销;除非加载冷态日志,否则还会漏掉这些日志;并会重复实现已经由 `ctx.sessionQuery` 负责的语义提取与源对齐。 +- **首版即加入 trigram 或模糊搜索**:不予采纳,因为这会改变索引大小、排序、短查询行为与产品预期。trigram 本身也无法解决双字查询。首版沿用现有后端契约,将召回扩展留作另一项基于度量结果的决策。 +- **返回事件地址并跳转至确切匹配位置**:本版不予采纳,因为对话虚拟化与稳定的事件导航需要单独的 UI 契约。会话级导航本身已有价值,无需让搜索与这项工作耦合。 +- **在侧边栏公开游标分页**:不予采纳,改为固定显示前 20 条结果并提示缩小查询范围;这样可使交互与取消状态保持有界。 + +## 后果 + +无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。 + +首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。 + +## 测试 + +宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、取消与故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index abe51abc2f..e78ab7b8ad 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9 -README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de +# pnpm run verify-translation-pairing --write apps/cli/README.md +README.md: bb3f4ee98700e4644535d1d3c05d29a9a558275d +README.zh.md: 44edea0f5b36598cfda1b61914da0b6622b973d6 diff --git a/apps/cli/README.md b/apps/cli/README.md index 42d2a9641c..bb3f4ee987 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable SQLite content index at `.sessions/session-query.db`. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0a62f8bb72..44edea0f5b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且在 `.sessions/session-query.db` 挂载一个可丢弃的 SQLite 内容索引。该索引在启动时不经扫描即打开,并在首次会话搜索时惰性对账已更改的实时日志和持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index efd75c1cf5..4adb5048b8 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,6 +89,13 @@ config: root: './.sessions' +# Lazy content index for session.search. Opening the database at boot does +# not scan logs; the first search reconciles changed live/persisted sessions. +- id: session-query-sqlite + name: '@deepseek-ai/dsh-session-query-sqlite' + config: + path: './.sessions/session-query.db' + - id: storage name: '@deepseek-ai/dsh-storage' diff --git a/apps/cli/package.json b/apps/cli/package.json index e0a3a51c94..2bc160eda8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -55,6 +55,8 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bbae7363df..7744ad5c55 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -26,6 +26,7 @@ const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md') const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md') +const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' @@ -90,39 +91,39 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read']) }, 400_000) - it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open')) - // Expand the collapsed group row, then open the revealed session row. - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() + it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) + const search = page.getByPlaceholder('搜索名称或关键词', { exact: false }) + // The cold row has not been opened, so only the persisted log can satisfy + // this query. First search lazily reconciles the SQLite content index. + await search.fill('zzzqx-no-such-session') + await page.getByText('没有匹配结果').waitFor({ timeout: 30_000 }) + await expect.poll( + () => page.getByRole('tree', { name: '搜索结果' }).getByRole('treeitem').count(), + { timeout: 10_000 }, + ).toBe(0) + + await search.fill('WATERFALL') + const resultTree = page.getByRole('tree', { name: '搜索结果' }) + const result = resultTree.getByRole('treeitem') + await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1) + await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), { + timeout: 10_000, + }).toBeGreaterThanOrEqual(1) + const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + 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') 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) - }, 90_000) - - it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) - // Runs after the session is open: a cold summary carries no title (the - // sidebar shows the cwd basename), and the durable title lands with the - // attach subscription's baseline — which is itself worth pinning: search - // matches the title the user sees, not a hidden cold field. - const search = page.getByPlaceholder('Search name, keywords', { exact: false }) - await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) - // Negative: a garbage query empties the tree (group rows hide too). - await search.fill('zzzqx-no-such-session') - await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0) - // Positive: a title word narrows to the matched session + its group, - // force-expanded by search mode (case-insensitive client-side filter). - await search.fill('navscenario') - await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) - // Clear restores the unfiltered tree. - await page.getByRole('button', { name: 'Clear search' }).click() + await page.getByRole('button', { name: '清除搜索' }).click() await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) - }, 60_000) + }, 90_000) it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) @@ -184,7 +185,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md', + 'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md', + 'waterfall.expected.md', 'details-open.expected.md', ]) }) }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 34d0e5f123..f809081d3d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -163,6 +163,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 0 } } +/** Fixture mirror of first-party message extraction used by session-query. */ +function searchBlockText(block: ContentBlock): string[] { + switch (block.type) { + case 'text': + case 'reasoning': + return [block.text] + case 'tool-call': + return [block.name, block.arguments] + case 'tool-result': + return block.content.flatMap(searchBlockText) + default: + return [] + } +} + +/** One current-surface user/assistant/steering document, if searchable. */ +function searchEventText(event: SessionEvent): string { + if ( + event.type !== 'user/message' + && event.type !== 'assistant/message' + && event.type !== 'steering/message' + ) return '' + return event.data.content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n') +} + +/** + * Browser-safe approximation of SQLite FTS5 unicode61 token boundaries. + * Keeping phrase matching token-based prevents the development fixture from + * promising arbitrary within-token substring behavior that production lacks. + */ +function searchTokens(value: string): string[] { + return value + .normalize('NFD') + .replace(/\p{M}+/gu, '') + .toLowerCase() + .match(/[\p{L}\p{N}\p{Co}]+/gu) ?? [] +} + +/** Count exact contiguous token-phrase occurrences in one fixture document. */ +function phraseMatchCount(document: readonly string[], phrase: readonly string[]): number { + if (phrase.length === 0 || phrase.length > document.length) return 0 + let count = 0 + for (let start = 0; start <= document.length - phrase.length; start++) { + if (phrase.every((token, offset) => document[start + offset] === token)) count++ + } + return count +} + +/** One-line fixture excerpt, bounded so the sidebar remains readable. */ +function searchSnippet(value: string): string { + const oneLine = value.replace(/\s+/gu, ' ').trim() + return oneLine.length <= 120 ? oneLine : `${oneLine.slice(0, 117)}…` +} + +interface FixtureSearchCandidate { + sessionId: SessionId + seq: number + time: number + text: string + matchCount: number + documentLength: number +} + +/** Same rank keys as session-query-sqlite's cross-session result order. */ +function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number { + if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount + if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength + if (a.time !== b.time) return b.time - a.time + if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1 + return b.seq - a.seq +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -547,6 +620,42 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return { sessions: { list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), + search: (request, signal) => { + if (signal.aborted) { + return err(request, { + code: 'cancelled', + message: 'fixture session search was aborted', + details: {}, + }) + } + const query = searchTokens(request.payload.query) + const matches = sessions.flatMap((summary) => { + const log = logs.get(summary.sessionId) ?? [] + const current = new Set(foldSurface(log).nodes) + const best = log.flatMap((event): FixtureSearchCandidate[] => { + if (!current.has(event.seq)) return [] + const eventText = searchEventText(event) + const matchCount = phraseMatchCount(searchTokens(eventText), query) + if (matchCount === 0) return [] + return [{ + sessionId: summary.sessionId, + seq: event.seq, + time: event.time, + text: eventText, + matchCount, + documentLength: Array.from(eventText).length, + }] + }).sort(compareSearchCandidates)[0] + return best === undefined ? [] : [best] + }).sort(compareSearchCandidates) + return ok(request, { + items: matches.slice(0, 20).map(match => ({ + sessionId: match.sessionId, + snippet: searchSnippet(match.text), + })), + hasMore: matches.length > 20, + }) + }, create: async (request) => { const workspace = request.payload.workspaceId === undefined ? undefined @@ -892,20 +1001,30 @@ export class FixtureApiClient extends AbstractApiClient { protected override async callUnary( method: K, payload: RequestPayload, + signal?: AbortSignal, ): Promise>> { const request = rpcRequest(payload) const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload } this.onEnvelope(full) - const response = await this.dispatch(method, request as RpcRequest) as RpcResponse> + const response = await this.dispatch( + method, + request as RpcRequest, + signal ?? new AbortController().signal, + ) as RpcResponse> const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result } this.onEnvelope(fullResponse) return response } /** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */ - private dispatch(method: keyof RpcMethodMap, request: RpcRequest): Promise> { + private dispatch( + method: keyof RpcMethodMap, + request: RpcRequest, + signal: AbortSignal, + ): Promise> { switch (method) { case 'session.list': return this.api.sessions.list(request) + case 'session.search': return this.api.sessions.search(request, signal) case 'session.create': return this.api.sessions.create(request) case 'session.history': return this.api.sessions.history(request) case 'session.prompt': return this.api.sessions.prompt(request) @@ -916,8 +1035,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.rename': return this.api.workspace.rename(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'command.list': return this.api.commands.list(request) - // The in-memory execute never blocks, so a never-aborting signal is faithful here. - case 'command.execute': return this.api.commands.execute(request, new AbortController().signal) + case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index d4505eb659..8dd7b9b1e9 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -11,7 +11,7 @@ import { WebApiClient } from './web-api-client.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { - ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, + ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bf7295cc50..d7785697e8 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, - RpcRequest, RpcResponse, SessionId, SkillEntry, + RpcRequest, RpcResponse, SessionId, SessionSearchItem, SkillEntry, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -43,6 +43,8 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [], hasMore: false })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = @@ -55,12 +57,17 @@ export class FakeApiClient implements IApiClient { private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] + lastSearchSignal: AbortSignal | undefined // Parameter annotations below are local structural types on purpose: the CI // lint lane runs without built artifacts, where IApiClient's wire types // (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument. readonly sessions: IApiClient['sessions'] = { list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)), + search: (payload: unknown, signal?: AbortSignal) => { + this.lastSearchSignal = signal + return this.record('session.search', payload, this.onSearch(payload)) + }, create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 0374f92b3b..9bf0173237 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -48,6 +48,37 @@ describe('createFixtureApi', () => { expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material }) + it('searches current message text with literal unicode61-style token phrases', async () => { + const api = createFixtureApi() + const signal = new AbortController().signal + const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal) + expect(phrase.result).toMatchObject({ + ok: true, + value: { + items: [{ sessionId: 'fx-alpha' }], + hasMore: false, + }, + }) + if (!phrase.result.ok) throw new Error('search failed') + expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息') + + const substring = await api.sessions.search(req({ query: 'ixtur' }), signal) + expect(substring.result).toEqual({ + ok: true, + value: { items: [], hasMore: false }, + }) + const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal) + expect(punctuationOnly.result).toEqual({ + ok: true, + value: { items: [], hasMore: false }, + }) + + const aborted = new AbortController() + aborted.abort() + await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal)) + .resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } }) + }) + it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => { const api = createFixtureApi() const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) @@ -601,6 +632,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('covers the whole unary dispatch table', async () => { const client = new FixtureApiClient() + expect((await client.sessions.search( + { query: 'fixture' }, + new AbortController().signal, + )).result.ok).toBe(true) const created = await client.sessions.create({}) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 5e73979b0b..2e854cc6dd 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 4724ebc75d441252245a0e811a4ae34f8b529a98 -README.zh.md: 6a0076742efccaf946910c77c77a9b74194b9dc5 +# pnpm run verify-translation-pairing --write packages/client/runtime/README.md +README.md: c83e70d574b85ff1128f48725b03811411b62fe2 +README.zh.md: ab97832760bb7f147211cf430aa5c7601b4dcbd8 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4724ebc75d..c83e70d574 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -10,6 +10,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. +`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. + ## New Session and the blank mirror `WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6a0076742e..ab97832760 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -10,6 +10,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 +`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。 + ## New Session 与 blank 镜像 `WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b1300a192c..620ccb885d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -18,7 +18,7 @@ export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, } from './sessions/service.ts' -export type { SessionListPhase } from './sessions/manager.ts' +export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts' export type { WorkspaceListPhase } from './workspaces/manager.ts' export type { WorkspaceListState } from './workspaces/service.ts' export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 694768ebbc..93a9b8e597 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -2,7 +2,10 @@ // dispatch entry + list state, constructed and held by SessionsService (one per client runtime). // List data never enters zustand; React connects via subscribe/getListSnapshot. -import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' +import type { + IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, + SessionSummary, WorkspaceId, +} from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -22,6 +25,12 @@ import { Session } from './session.ts' */ export type SessionListPhase = 'pending' | 'ready' +/** Request-local content hit returned to sidebar search consumers. */ +export interface SessionSearchResultItem { + sessionId: SessionId + snippet: string +} + /** Immutable session-list snapshot for useSessionList. */ export interface SessionListSnapshot { items: readonly SessionListEntry[] @@ -213,6 +222,24 @@ export class SessionManager { return this.listInflight } + /** + * Search visible session message content without adding transient query + * state to the list snapshot. + * @param query - non-blank literal phrase. + * @param signal - cancellation for superseded UI queries. + * @returns the Host result or a folded transport error. + */ + async search( + query: string, + signal: AbortSignal, + ): Promise> { + try { + return (await this.api.sessions.search({ query }, signal)).result + } catch (error: unknown) { + return transportError(error) + } + } + /** * Contract session.create; on success merge into summaries immediately (no * wait for the next refresh). A created session is blank by definition diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec8ddc2354..9f239a47dd 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -16,7 +16,9 @@ * survives frozen (read-only view) until the stage moves on. */ import type { Context, Fiber } from 'cordis' -import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' +import type { + IApiClient, RpcError, RpcResult, SessionId, WorkspaceId, +} from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, } from '@deepseek-ai/dsh-client-ui-slots' @@ -24,7 +26,7 @@ import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' -import type { SessionListPhase } from './manager.ts' +import type { SessionListPhase, SessionSearchResultItem } from './manager.ts' import type { Session } from './session.ts' /** Session list row projected from the host list RPC plus live stream increments. */ @@ -318,6 +320,20 @@ export class SessionsService { return this.manager.refreshList() } + /** + * Search the Host's visible message-content index. Results stay + * request-local; the list snapshot remains the metadata authority. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search. + * @returns bounded results or a business/transport error. + */ + search( + query: string, + signal: AbortSignal, + ): Promise> { + return this.manager.search(query, signal) + } + /** * Route a mux stream envelope into the Session object layer. * @param envelope - validated mux stream envelope. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index dcb334f6ea..504b9431aa 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, - RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry, + RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -60,6 +60,8 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [], hasMore: false })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = @@ -72,12 +74,17 @@ export class FakeApiClient implements IApiClient { private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] + lastSearchSignal: AbortSignal | undefined // Parameters carry local structural annotations: the CI lint lane runs // without built lib/, so IApiClient's indexed-access types collapse to any // and inferred parameters would trip no-unsafe-argument. readonly sessions: IApiClient['sessions'] = { list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)), + search: (payload: unknown, signal?: AbortSignal) => { + this.lastSearchSignal = signal + return this.record('session.search', payload, this.onSearch(payload)) + }, create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index ee76d885ab..5d42aa685f 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -194,6 +194,49 @@ describe('list lifecycle', () => { }) }) +describe('search', () => { + it('returns bounded Host results and forwards the caller signal', async () => { + const api = new FakeApiClient() + api.onSearch = () => Promise.resolve(ok({ + items: [{ sessionId: S1, snippet: 'matching excerpt' }], + hasMore: true, + })) + const manager = new SessionManager(api) + const signal = new AbortController().signal + + await expect(manager.search('exact phrase', signal)).resolves.toEqual({ + ok: true, + value: { + items: [{ sessionId: S1, snippet: 'matching excerpt' }], + hasMore: true, + }, + }) + expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }]) + expect(api.lastSearchSignal).toBe(signal) + }) + + it('preserves business errors and folds transport failures', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + api.onSearch = () => Promise.resolve(err({ + code: 'internal', + message: 'index unavailable', + details: {}, + })) + const signal = new AbortController().signal + await expect(manager.search('first', signal)).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: 'index unavailable' }, + }) + + api.onSearch = () => Promise.reject(new Error('wire down')) + await expect(manager.search('second', signal)).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: 'wire down' }, + }) + }) +}) + describe('host frame routing', () => { it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => { const api = new FakeApiClient() diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 44ab4ffb4f..0d19b914b7 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -69,6 +69,29 @@ describe('list store projection', () => { }) }) +describe('search', () => { + it('delegates transient content search without changing the list snapshot', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + const before = b.svc.list.getSnapshot() + b.api.onSearch = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }], + hasMore: false, + })) + const signal = new AbortController().signal + + await expect(b.svc.search('needle', signal)).resolves.toEqual({ + ok: true, + value: { + items: [{ sessionId: 's1', snippet: 'matching excerpt' }], + hasMore: false, + }, + }) + expect(b.api.lastSearchSignal).toBe(signal) + expect(b.svc.list.getSnapshot()).toBe(before) + }) +}) + describe('scope tree', () => { it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => { const b = bench() diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index d0f2d0a20a..f89fbee5d4 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33 -README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010 +# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md +README.md: 9cb919a1a64394d5e116d35bdddfdee738994a02 +README.zh.md: b3add7f89cb0feb7f44238b7199d0633cdfbf641 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index e0247b3e26..9cb919a1a6 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals. +Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation modals. + +The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search 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, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. @@ -18,5 +20,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No Workspace rename/delete controls** — the picker supports selection and creation only. +- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. +- **No Workspace delete control** — the browser supports creation and rename, while the picker supports selection and creation. - **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 92ef463faa..b3add7f89c 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。 +共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建模态框。 + +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。 @@ -18,5 +20,6 @@ ## 已知限制与暂缓事项 -- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。 +- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 +- **没有 Workspace 删除控件**:浏览器支持创建和重命名,选择器支持选择和创建。 - **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index d6375cb698..96af22a309 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -209,6 +209,22 @@ padding-bottom: 12px; } +.list > [role='treeitem'] + [role='treeitem'] { + margin-top: 4px; +} + +.searchStatus, +.searchWarning { + padding: 10px 12px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.searchWarning { + color: var(--dsw-alias-label-secondary); +} + /* One workspace section: header row + expanded session run. Rows inside keep the former flat-list 4px gap as sibling margins; the inter-group breathing room (figma 133:7661 batch separator, 20px after an expanded diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 0dc6485929..d703674c01 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -13,16 +13,20 @@ import { Button, IconCloseFill14, IconPersonalizationOutline16, IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { + SessionSearchResultItem, WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from './contract/slots.ts' import type { SessionNode } from './tree.ts' -import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts' -import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx' +import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts' +import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx' import { WorkspaceCreateFlow } from './WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' /** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ const EXPAND_SLIDE_MS = 300 +/** Pause between the latest keystroke and a Host content-search request. */ +const SEARCH_DEBOUNCE_MS = 250 const GROUP_BY_ITEMS = [ { type: 'label' as const, id: 'group-by', text: 'Group by' }, @@ -83,14 +87,12 @@ type SessionTreeProps = Pick< 'useSessions' | 'startSession' | 'open' | 'insertSessionBefore' > & { workspaces: readonly WorkspaceView[] - /** Live search filter owned by the browser root (the query outlives the tree). */ - query: string /** Open the browser-owned rename dialog for a real Workspace group. */ onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) { +function SessionTree({ useSessions, startSession, open, workspaces, onRenameRequest, insertSessionBefore }: SessionTreeProps) { const list = useSessions((s) => s) const current = list.current const [expandedProjects, setExpandedProjects] = useState([]) @@ -106,8 +108,8 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) }, [current, currentGroup]) const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), - [list, workspaces, expandedProjects, expandedSessions, query], + () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions }), + [list, workspaces, expandedProjects, expandedSessions], ) const now = Date.now() @@ -115,7 +117,7 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
{groups.length === 0 && ( -
{query === '' ? 'No sessions yet' : 'No matches'}
+
No sessions yet
)} {groups.map(group => ( // Group section: header row + expanded session subtree. The @@ -136,10 +138,10 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen }} /> {group.sessions.map((node, index) => { - // Draggable: real-workspace group roots outside search. The drag + // Draggable: real-workspace group roots. The drag // never leaves its group — rows of other groups show no markers // and reject drops (visual movement confined to this section). - const draggable = group.workspaceId !== undefined && query === '' + const draggable = group.workspaceId !== undefined const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId const dragProps = !draggable || group.workspaceId === undefined ? undefined : { start: () => { @@ -192,15 +194,15 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, query }: Pick) { +function FlatList({ useSessions, open }: Pick) { const list = useSessions((s) => s) - const rows = useMemo(() => deriveFlat(list, { query }), [list, query]) + const rows = useMemo(() => deriveFlat(list), [list]) const now = Date.now() return (
{rows.length === 0 && ( -
{query === '' ? 'No sessions yet' : 'No matches'}
+
No sessions yet
)} {rows.map(node => ( & { + workspaces: readonly WorkspaceView[] + query: string + remote: RemoteSearchState +}) { + const list = useSessions((s) => s) + const currentRemote = remote.query === query + ? remote + : { query, status: 'loading' as const, items: [], hasMore: false } + const results = useMemo( + () => deriveSearchResults(list, workspaces, query, currentRemote), + [list, workspaces, query, currentRemote], + ) + const pending = currentRemote.status === 'loading' + const failed = currentRemote.status === 'error' + + return ( +
+
+ {results.items.map(result => ( + + ))} + {pending && ( +
正在搜索历史…
+ )} + {failed && ( +
+ 历史内容搜索暂时不可用,仍显示名称匹配。 +
+ )} + {!pending && results.items.length === 0 && ( +
没有匹配结果
+ )} + {results.hasMore && ( +
仅显示前 20 项,请缩小搜索范围。
+ )} +
+ +
+ ) +} + /** * Render the browsing region. * @param props - composed slot props (shell owner share + store + injected actions). @@ -238,12 +301,20 @@ export function WorkspaceBrowser({ renameWorkspace, insertSessionBefore, createWorkspace, + searchSessions, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') + const normalizedQuery = query.trim() + const [remoteSearch, setRemoteSearch] = useState({ + query: '', + status: 'idle', + items: [], + hasMore: false, + }) const searchInput = useRef(null) // Section-header + opens the picker menu (same popover in wide and rail // states; the menu anchors on this button). @@ -263,6 +334,43 @@ export function WorkspaceBrowser({ } }, [wide, searchOnExpand]) + useEffect(() => { + if (normalizedQuery === '') { + setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false }) + return + } + const controller = new AbortController() + setRemoteSearch({ + query: normalizedQuery, + status: 'loading', + items: [], + hasMore: false, + }) + const timer = window.setTimeout(() => { + searchSessions(normalizedQuery, controller.signal).then((result) => { + if (controller.signal.aborted) return + setRemoteSearch({ + query: normalizedQuery, + status: 'ready', + items: result.items, + hasMore: result.hasMore, + }) + }).catch(() => { + if (controller.signal.aborted) return + setRemoteSearch({ + query: normalizedQuery, + status: 'error', + items: [], + hasMore: false, + }) + }) + }, SEARCH_DEBOUNCE_MS) + return () => { + window.clearTimeout(timer) + controller.abort() + } + }, [normalizedQuery, searchSessions]) + // Rename dialog (browser-owned so it outlives row unmounts during collapse). const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null) const [renameDraft, setRenameDraft] = useState('') @@ -331,11 +439,11 @@ export function WorkspaceBrowser({ {/* Expanded: the row is a click-to-focus field (the leading icon is decorative). Rail: the icon is the region's search control. */}
{ if (wide) searchInput.current?.focus() }}> - + + ) +} + /** Pointer-position half of a row (insert line above or below). */ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { const rect = e.currentTarget.getBoundingClientRect() diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index c0adfadd6f..76dcfe924e 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -3,7 +3,9 @@ * Unassigned Sessions trail under Ungrouped; only the selected blank Session * remains visible. */ -import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { + SessionId, SessionListState, SessionSearchResultItem, SessionSummary, WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' /** Group key for Sessions outside every Workspace. */ export const UNGROUPED_KEY = '' @@ -15,7 +17,7 @@ export const UNGROUPED_LABEL = 'Ungrouped' export interface SessionNode { id: SessionId title: string - /** Visible children, already expansion/search-filtered (empty when folded). */ + /** Visible children, already expansion-filtered (empty when folded). */ children: readonly SessionNode[] /** The session HAS children in the data (the twist renders even while folded). */ hasChildren: boolean @@ -41,11 +43,25 @@ export interface GroupNode { sessions: readonly SessionNode[] } +/** One flat search row combining list metadata with an optional content match. */ +export interface SearchResultNode { + id: SessionId + title: string + workspace: string + running: boolean + snippet?: string +} + +/** Bounded merged search projection plus the refine-query hint bit. */ +export interface SearchResultSet { + items: readonly SearchResultNode[] + hasMore: boolean +} + /** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */ export interface TreeView { expandedProjects: readonly string[] expandedSessions: readonly string[] - query: string } interface Group { @@ -204,47 +220,15 @@ function buildVisible(g: Group, expandedSessions: ReadonlySet): SessionN return g.roots.map(walk).filter((n): n is SessionNode => n !== null) } -/** Matched sessions plus their ancestor chains (forced visible under search). */ -function searchVisible(g: Group, q: string): Set { - const visible = new Set() - for (const m of g.summaries.values()) { - if (!sessionTitle(m).toLowerCase().includes(q)) continue - let cur: SessionSummary | undefined = m - while (cur !== undefined && !visible.has(cur.id)) { - visible.add(cur.id) - cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined - } - } - return visible -} - -function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id) || !visible.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid)) - const children = kids.map(walk).filter((n): n is SessionNode => n !== null) - return sessionNode(s, children, kids.length > 0, kids.length > 0) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - /** * Derive the nested workspace browser group structure. * - * Normal mode: every group shows; sessions populate under expanded groups, - * descending only into expanded sessions. Search mode (non-blank query, - * case-insensitive display-title substring): expansion state is ignored — - * matched sessions and their ancestor chains are forced visible, groups - * without a display-title or label hit are dropped, and a label-only hit - * keeps the bare group header. Blank sessions are excluded everywhere. + * Every group shows; sessions populate under expanded groups, descending + * only into expanded sessions. Blank sessions are excluded except for the + * selected provisional New Session row. * @param list - sessions list snapshot (`current` feeds containsCurrent). * @param workspaces - real workspaces in stable Host order. - * @param view - local expansion arrays and search query. + * @param view - local expansion arrays. * @returns group sections in render order. */ export function deriveGroups( @@ -252,7 +236,6 @@ export function deriveGroups( workspaces: readonly WorkspaceView[], view: TreeView, ): GroupNode[] { - const q = view.query.trim().toLowerCase() const expandedProjects = new Set(view.expandedProjects) const expandedSessions = new Set(view.expandedSessions) const currentGroup = list.current === undefined @@ -261,32 +244,17 @@ export function deriveGroups( ?? UNGROUPED_KEY const groups: GroupNode[] = [] for (const g of groupByWorkspace(list, workspaces)) { - if (q === '') { - const expanded = expandedProjects.has(g.key) - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded, - containsCurrent: g.key === currentGroup, - sessions: expanded ? buildVisible(g, expandedSessions) : [], - }) - } else { - const visible = searchVisible(g, q) - if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded: visible.size > 0, - containsCurrent: g.key === currentGroup, - sessions: buildSearch(g, visible), - }) - } + const expanded = expandedProjects.has(g.key) + groups.push({ + key: g.key, + workspaceId: g.workspaceId, + cwd: g.cwd, + label: g.label, + sessionCount: g.summaries.size, + expanded, + containsCurrent: g.key === currentGroup, + sessions: expanded ? buildVisible(g, expandedSessions) : [], + }) } return groups } @@ -295,25 +263,97 @@ export function deriveGroups( * Derive the flat session list ("In one list" mode): every session — fork * children included — as a top-level row, strictly newest-first. No grouping, * no parent/child adjacency; rows reuse SessionNode with children always - * empty so the renderer stays branch-free. Search mode filters by - * case-insensitive display-title substring. + * empty so the renderer stays branch-free. * @param list - sessions list snapshot. - * @param view - the search query (expansion state does not apply). * @returns flat rows in render order. */ -export function deriveFlat(list: SessionListState, view: Pick): SessionNode[] { - const q = view.query.trim().toLowerCase() +export function deriveFlat(list: SessionListState): SessionNode[] { const rows: SessionSummary[] = [] for (const id of list.ids) { const s = list.byId[id] if (s === undefined || !sessionVisible(s, list.current)) continue - if (q !== '' && !sessionTitle(s).toLowerCase().includes(q)) continue rows.push(s) } rows.sort(byRecency) return rows.map(s => sessionNode(s, [], false, false)) } +/** Maximum rows rendered by the basic search surface. */ +const SEARCH_RESULT_LIMIT = 20 + +/** + * Merge immediate title/Workspace substring matches with ranked Host content + * matches. Local rows lead newest-first, content-only rows retain backend + * order, and duplicate sessions receive the backend snippet in place. + * @param list - session metadata authority. + * @param workspaces - Workspace membership and display labels. + * @param query - caller text; surrounding whitespace is ignored. + * @param content - ranked Host content-search page. + * @returns at most 20 deduplicated flat rows and a refine-query hint bit. + */ +export function deriveSearchResults( + list: SessionListState, + workspaces: readonly WorkspaceView[], + query: string, + content: { items: readonly SessionSearchResultItem[]; hasMore: boolean }, +): SearchResultSet { + const q = query.trim().toLowerCase() + if (q === '') return { items: [], hasMore: false } + + const workspaceBySession = new Map() + for (const workspace of workspaces) { + for (const sessionId of workspace.sessionIds) { + if (!workspaceBySession.has(sessionId)) workspaceBySession.set(sessionId, workspace.title) + } + } + const labelOf = (summary: SessionSummary): string => + workspaceBySession.get(summary.id) ?? projectLabel(summary.cwd) + const contentBySession = new Map() + for (const item of content.items) { + if (!contentBySession.has(item.sessionId)) contentBySession.set(item.sessionId, item) + } + + const local: SessionSummary[] = [] + for (const id of list.ids) { + const summary = list.byId[id] + if (summary === undefined || !sessionVisible(summary, list.current)) continue + if ( + sessionTitle(summary).toLowerCase().includes(q) + || labelOf(summary).toLowerCase().includes(q) + ) { + local.push(summary) + } + } + local.sort(byRecency) + + const ordered: SessionSummary[] = [] + const included = new Set() + const include = (summary: SessionSummary): void => { + if (included.has(summary.id)) return + included.add(summary.id) + ordered.push(summary) + } + for (const summary of local) include(summary) + for (const item of content.items) { + const summary = list.byId[item.sessionId] + if (summary !== undefined && sessionVisible(summary, list.current)) include(summary) + } + + return { + items: ordered.slice(0, SEARCH_RESULT_LIMIT).map((summary) => { + const match = contentBySession.get(summary.id) + return { + id: summary.id, + title: sessionTitle(summary), + workspace: labelOf(summary), + running: summary.running, + ...match === undefined ? {} : { snippet: match.snippet }, + } + }), + hasMore: content.hasMore || ordered.length > SEARCH_RESULT_LIMIT, + } +} + /** * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). * @param updatedAt - epoch ms of the session's last activity. diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 8961ccdb83..b2a70d0f0b 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -19,11 +19,25 @@ async function bench() { const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() const clear = vi.fn() + const search = vi.fn(async () => ({ + ok: true as const, + value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false }, + })) ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore, } as never) - ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear } + ctx.provide('sessions', { open, clear, search } as never) + return { + ctx, + slots: ctx.get('slots') as SlotsService, + create, + startSession, + rename, + insertSessionBefore, + open, + clear, + search, + } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -66,6 +80,12 @@ describe('ui-workspace apply', () => { expect(b.startSession).toHaveBeenLastCalledWith(undefined) browser.open('session' as never) expect(b.open).toHaveBeenCalledWith('session') + const signal = new AbortController().signal + await expect(browser.searchSessions('match', signal)).resolves.toEqual({ + items: [{ sessionId: 'session', snippet: 'match' }], + hasMore: false, + }) + expect(b.search).toHaveBeenCalledWith('match', signal) await browser.renameWorkspace('ws' as never, 'renamed') expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never) @@ -78,6 +98,19 @@ describe('ui-workspace apply', () => { expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) }) + it('rejects the browser search callback on a runtime business error', async () => { + const b = await bench() + b.search.mockImplementationOnce(async () => ({ + ok: false, + error: { code: 'internal', message: 'index unavailable', details: {} }, + }) as never) + declare(b.slots, 'sidebar.workspaces') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() + await expect(browser.searchSessions('needle', new AbortController().signal)) + .rejects.toThrow('index unavailable') + }) + it('unregisters every entry on teardown', async () => { const b = await bench() declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace', 'conversation.empty.workspace') diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 70cfb36940..fc22398cb0 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { RowDragProps } from '../src/client/rows/Rows.tsx' -import { ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' -import type { GroupNode, SessionNode } from '../src/client/tree.ts' +import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' +import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts' afterEach(cleanup) @@ -38,6 +38,25 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): } describe('workspace browser rows', () => { + it('renders a selected content-search row and opens only its session', () => { + const onOpen = vi.fn() + const result: SearchResultNode = { + id: sid('result'), + title: 'Result title', + workspace: 'Workspace context', + running: true, + snippet: 'matching message excerpt', + } + render() + const row = screen.getByRole('treeitem') + expect(row.getAttribute('aria-selected')).toBe('true') + expect(screen.getByText('Workspace context')).toBeTruthy() + expect(screen.getByText('matching message excerpt')).toBeTruthy() + expect(row.hasAttribute('draggable')).toBe(false) + fireEvent.click(row) + expect(onOpen).toHaveBeenCalledWith(result.id) + }) + it('renders an active Workspace and keeps its create action separate from toggling', () => { const onToggle = vi.fn() const onCreate = vi.fn() diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 4af5d5f70c..e308c79eec 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest' import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { + deriveFlat, deriveGroups, deriveSearchResults, formatRelativeTime, projectLabel, + UNGROUPED_KEY, UNGROUPED_LABEL, +} from '../src/client/tree.ts' import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId @@ -16,12 +19,12 @@ const list = (...items: SessionSummary[]): SessionListState => ({ current: undefined, phase: 'ready', }) -const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({ - workspaceId: wid(id), path: `/projects/${id}`, title: id, +const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ + workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }) -const view = (expandedProjects: readonly string[] = [], query = '') => ({ - expandedProjects, expandedSessions: [] as string[], query, +const view = (expandedProjects: readonly string[] = []) => ({ + expandedProjects, expandedSessions: [] as string[], }) describe('deriveGroups', () => { @@ -59,21 +62,6 @@ describe('deriveGroups', () => { expect(strayGroups.map(group => group.key)).toEqual(['first']) }) - it('searches the current blank session by its New Session title', () => { - const currentBlank = { ...summary('opaque-current', 5), blank: true } - const staleBlank = { ...summary('new session stale', 4), blank: true } - const sessions = { - ...list(currentBlank, staleBlank), - current: currentBlank.id, - } - const groups = deriveGroups( - sessions, [workspace('first', ['opaque-current', 'new session stale'])], view([], 'new session'), - ) - expect(groups[0]!.sessions.map(session => session.id)).toEqual([currentBlank.id]) - expect(groups[0]!.sessions[0]!.title).toBe('New Session') - expect(groups[0]!.sessionCount).toBe(1) - }) - it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => { const parent = summary('parent', 1) const oldChild = { ...summary('old-child', 10), parentId: parent.id } @@ -87,7 +75,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), [], - { expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' }, + { expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id] }, ) expect(groups).toHaveLength(1) @@ -113,31 +101,6 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) }) - it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => { - const root = { ...summary('root', 1), displayTitle: 'Ancestor' } - const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id } - const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id } - const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') } - const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') } - const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') } - const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') } - const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB) - const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle')) - - expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([ - root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id, - ]) - - const labelOnly = deriveGroups( - list(summary('hidden', 1)), - [workspace('label-hit', ['hidden']), workspace('other', [])], - view([], 'label'), - ) - expect(labelOnly).toEqual([ - expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }), - ]) - }) - it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => { const owned = summary('owned', 1) const loose = summary('loose', 2) @@ -155,21 +118,15 @@ describe('deriveFlat', () => { const child = { ...summary('child', 30), parentId: parent.id } const tieB = summary('tie-b', 20) const tieA = summary('tie-a', 20) - const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' }) + const rows = deriveFlat(list(parent, child, tieB, tieA)) expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) // Rows are branch-free: no children, no expansion. expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true) }) - it('search filters by case-insensitive display-title substring', () => { - const hit = { ...summary('hit', 2), displayTitle: 'Needle row' } - const miss = { ...summary('miss', 1), displayTitle: 'Other' } - expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')]) - }) - it('tolerates ids whose summary has not landed yet', () => { const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } - expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')]) + expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')]) }) it('shows only the current blank session with its New Session title', () => { @@ -179,11 +136,112 @@ describe('deriveFlat', () => { ...list(summary('real', 1), currentBlank, staleBlank), current: currentBlank.id, } - const rows = deriveFlat(sessions, { query: '' }) + const rows = deriveFlat(sessions) expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')]) expect(rows.map(row => row.title)).toEqual(['New Session', 'real']) - expect(deriveFlat(sessions, { query: 'new session' }).map(row => row.id)).toEqual([currentBlank.id]) - expect(deriveFlat(sessions, { query: 'stale-blank' })).toEqual([]) + }) +}) + +describe('deriveSearchResults', () => { + it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => { + const titleHit = summary('title-hit', 30, '/projects/a') + titleHit.displayTitle = 'Needle title' + const workspaceHit = summary('workspace-hit', 20, '/projects/b') + workspaceHit.displayTitle = 'Ordinary title' + const contentHit = summary('content-hit', 10, '/projects/c') + const sessions = list(titleHit, workspaceHit, contentHit) + const result = deriveSearchResults( + sessions, + [ + workspace('a', ['title-hit'], 'Alpha'), + workspace('b', ['workspace-hit'], 'Needle Workspace'), + ], + ' NEEDLE ', + { + items: [ + { sessionId: contentHit.id, snippet: 'body needle excerpt' }, + { sessionId: titleHit.id, snippet: 'title session body excerpt' }, + { sessionId: sid('unknown'), snippet: 'not in session.list' }, + ], + hasMore: false, + }, + ) + + expect(result).toEqual({ + items: [ + { + id: titleHit.id, + title: 'Needle title', + workspace: 'Alpha', + running: false, + snippet: 'title session body excerpt', + }, + { + id: workspaceHit.id, + title: 'Ordinary title', + workspace: 'Needle Workspace', + running: false, + }, + { + id: contentHit.id, + title: 'content-hit', + workspace: 'c', + running: false, + snippet: 'body needle excerpt', + }, + ], + hasMore: false, + }) + }) + + it('shows only the current blank row and uses its New Session display title', () => { + const currentBlank = { ...summary('opaque-current', 5), blank: true } + const staleBlank = { ...summary('new session stale', 4), blank: true } + const sessions = { + ...list(currentBlank, staleBlank), + current: currentBlank.id, + } + const result = deriveSearchResults( + sessions, + [workspace('first', ['opaque-current', 'new session stale'])], + 'new session', + { + items: [ + { sessionId: staleBlank.id, snippet: 'stale body' }, + { sessionId: currentBlank.id, snippet: 'current body' }, + ], + hasMore: false, + }, + ) + expect(result.items).toEqual([{ + id: currentBlank.id, + title: 'New Session', + workspace: 'first', + running: false, + snippet: 'current body', + }]) + }) + + it('caps merged rows at 20 and preserves either local overflow or backend hasMore', () => { + const rows = Array.from({ length: 22 }, (_, index) => { + const item = summary(`s-${String(index).padStart(2, '0')}`, index) + item.displayTitle = `Needle ${String(index)}` + return item + }) + const overflow = deriveSearchResults(list(...rows), [], 'needle', { items: [], hasMore: false }) + expect(overflow.items).toHaveLength(20) + expect(overflow.hasMore).toBe(true) + + const backendMore = deriveSearchResults( + list(summary('body', 1)), + [], + 'needle', + { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true }, + ) + expect(backendMore.items).toHaveLength(1) + expect(backendMore.hasMore).toBe(true) + expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true })) + .toEqual({ items: [], hasMore: false }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e9b55e7b76..bce6fe6765 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -53,6 +53,7 @@ function mount(overrides: Partial = {}) { actions: store.actions, startSession: vi.fn(), open: vi.fn(), + searchSessions: vi.fn(async () => ({ items: [], hasMore: false })), renameWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), @@ -198,42 +199,168 @@ describe('WorkspaceBrowser', () => { b.store.actions.setGroupBy('flat') rerender(b, {}) expect(screen.getAllByText('New Session')).toHaveLength(1) - fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'new session' } }) + fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'new session' } }) expect(screen.getAllByText('New Session')).toHaveLength(1) }) - it('searches across groups, clears via the clear button, and shows the empty states', () => { - const sessions = sessionState([ - summary('needle-row', 2, { displayTitle: 'Needle row' }), - summary('other-row', 1, { displayTitle: 'Other row' }), - ]) - mount({ - useSessions: hook(sessions), - useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), - }) - const input = screen.getByPlaceholderText('Search name, keywords...') - fireEvent.change(input, { target: { value: 'needle' } }) - // Search forces matches visible without expansion state. - expect(screen.getByText('Needle row')).toBeTruthy() - expect(screen.queryByText('Other row')).toBeNull() - fireEvent.change(input, { target: { value: 'zzz' } }) - expect(screen.getByText('No matches')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) - expect(input.value).toBe('') - // Clicking the field row focuses the input (wide mode). - fireEvent.click(input.parentElement as HTMLElement) - expect(document.activeElement).toBe(input) + it('shows local metadata matches immediately, then clears back to the grouped tree', async () => { + vi.useFakeTimers() + try { + const sessions = sessionState([ + summary('needle-row', 2, { displayTitle: 'Needle row' }), + summary('other-row', 1, { displayTitle: 'Other row' }), + ]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), + }) + const input = screen.getByPlaceholderText('搜索名称或关键词…') + fireEvent.change(input, { target: { value: 'needle' } }) + expect(screen.getByRole('tree', { name: '搜索结果' })).toBeTruthy() + expect(screen.getByText('Needle row')).toBeTruthy() + expect(screen.queryByText('Other row')).toBeNull() + expect(screen.getByText('正在搜索历史…')).toBeTruthy() + + fireEvent.change(input, { target: { value: 'zzz' } }) + await act(async () => { await vi.advanceTimersByTimeAsync(250) }) + expect(screen.getByText('没有匹配结果')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) + expect(input.value).toBe('') + expect(screen.getByRole('tree', { name: 'Sessions' })).toBeTruthy() + // Clicking the field row focuses the input (wide mode). + fireEvent.click(input.parentElement as HTMLElement) + expect(document.activeElement).toBe(input) + } finally { + vi.useRealTimers() + } }) - it('shows the no-sessions empty state in both modes', () => { - const b = mount() - expect(screen.getByText('No sessions yet')).toBeTruthy() - b.store.actions.setGroupBy('flat') - rerender(b, {}) - expect(screen.getByText('No sessions yet')).toBeTruthy() - // Flat search misses show No matches. - fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } }) - expect(screen.getByText('No matches')).toBeTruthy() + it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => { + vi.useFakeTimers() + try { + const open = vi.fn() + const searchSessions = vi.fn(async () => ({ + items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }], + hasMore: true, + })) + mount({ + useSessions: hook(sessionState([ + summary('body-hit', 1, { displayTitle: 'Research notes' }), + ])), + useWorkspaces: hook(workspaceState([ + workspace('research', ['body-hit'], 'Research Workspace'), + ])), + open, + searchSessions, + }) + const input = screen.getByPlaceholderText('搜索名称或关键词…') + fireEvent.change(input, { target: { value: 'waterfall token' } }) + expect(screen.getByText('正在搜索历史…')).toBeTruthy() + expect(screen.queryByText('Research notes')).toBeNull() + + await act(async () => { await vi.advanceTimersByTimeAsync(250) }) + + expect(searchSessions).toHaveBeenCalledWith('waterfall token', expect.any(AbortSignal)) + expect(screen.getByText('Research notes')).toBeTruthy() + expect(screen.getByText('Research Workspace')).toBeTruthy() + expect(screen.getByText('…the waterfall token appears here…')).toBeTruthy() + expect(screen.getByText('仅显示前 20 项,请缩小搜索范围。')).toBeTruthy() + fireEvent.click(screen.getByRole('treeitem')) + expect(open).toHaveBeenCalledWith(sid('body-hit')) + expect(input.value).toBe('waterfall token') + } finally { + vi.useRealTimers() + } + }) + + it('keeps local matches and shows a lightweight warning when Host search fails', async () => { + vi.useFakeTimers() + try { + const searchSessions = vi.fn(async () => { throw new Error('index unavailable') }) + mount({ + useSessions: hook(sessionState([ + summary('local-hit', 1, { displayTitle: 'Needle title' }), + ])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])), + searchSessions, + }) + fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { + target: { value: 'needle' }, + }) + expect(screen.getByText('Needle title')).toBeTruthy() + await act(async () => { await vi.advanceTimersByTimeAsync(250) }) + expect(screen.getByText('Needle title')).toBeTruthy() + expect(screen.getByText('历史内容搜索暂时不可用,仍显示名称匹配。')).toBeTruthy() + expect(screen.queryByText('没有匹配结果')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('aborts a superseded request and ignores its stale result', async () => { + vi.useFakeTimers() + try { + let resolveFirst!: (value: { + items: { sessionId: SessionId; snippet: string }[] + hasMore: boolean + }) => void + const first = new Promise<{ + items: { sessionId: SessionId; snippet: string }[] + hasMore: boolean + }>((resolve) => { resolveFirst = resolve }) + const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first' + ? first + : Promise.resolve({ + items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }], + hasMore: false, + })) + mount({ + useSessions: hook(sessionState([ + summary('first-hit', 2, { displayTitle: 'Old result' }), + summary('second-hit', 1, { displayTitle: 'Fresh result' }), + ])), + searchSessions, + }) + const input = screen.getByPlaceholderText('搜索名称或关键词…') + fireEvent.change(input, { target: { value: 'first' } }) + await act(async () => { await vi.advanceTimersByTimeAsync(250) }) + const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal + expect(firstSignal.aborted).toBe(false) + + fireEvent.change(input, { target: { value: 'second' } }) + expect(firstSignal.aborted).toBe(true) + await act(async () => { await vi.advanceTimersByTimeAsync(250) }) + expect(screen.getByText('Fresh result')).toBeTruthy() + + await act(async () => { + resolveFirst({ + items: [{ sessionId: sid('first-hit'), snippet: 'stale excerpt' }], + hasMore: false, + }) + await Promise.resolve() + }) + expect(screen.queryByText('Old result')).toBeNull() + expect(screen.getByText('Fresh result')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('shows the no-sessions empty state in both modes and resolves an empty search', async () => { + vi.useFakeTimers() + try { + const b = mount() + expect(screen.getByText('No sessions yet')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('No sessions yet')).toBeTruthy() + fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'x' } }) + expect(screen.getByText('正在搜索历史…')).toBeTruthy() + await act(async () => { await vi.advanceTimersByTimeAsync(250) }) + expect(screen.getByText('没有匹配结果')).toBeTruthy() + } finally { + vi.useRealTimers() + } }) it('rail state renders icon controls that request expansion', () => { @@ -243,16 +370,16 @@ describe('WorkspaceBrowser', () => { const b = mount({ wide: false, expandSidebar }) // No wide chrome in rail state. expect(screen.queryByText('Workspaces')).toBeNull() - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(screen.queryByPlaceholderText('搜索名称或关键词…')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) expect(expandSidebar).toHaveBeenCalledTimes(1) // The wide flip mounts the input and focuses it after the slide. rerender(b, { wide: true }) - const input = screen.getByPlaceholderText('Search name, keywords...') + const input = screen.getByPlaceholderText('搜索名称或关键词…') act(() => { vi.advanceTimersByTime(300) }) expect(document.activeElement).toBe(input) // Wide search button is decorative (tabIndex -1, no expand call). - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) expect(expandSidebar).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -463,8 +590,8 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), }) - fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } }) + fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'needle' } }) const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement - expect(row.getAttribute('draggable')).toBe('false') + expect(row.hasAttribute('draggable')).toBe(false) }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index eb06e14d2d..7d323dbeea 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f -README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10 +# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md +README.md: deb1073c2ff9e7a533e595ee3f5537e649660e5b +README.zh.md: e3c521d5f6a09414d087e3fb142852e6d3eb0cb7 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 43ad70fa8b..deb1073c2f 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,6 +14,8 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway passes only those session ids and current-surface user, assistant, and steering messages to the optional `ctx.sessionQuery` service, returns at most 20 session/snippet pairs plus a refine-query bit, and forwards the carrier request signal for cancellation. A deployment without the service, or a failed index/query operation, returns an `internal` business error so clients can retain metadata-only matches. + The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cc95a7512f..e3c521d5f6 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,6 +14,8 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关仅将这些会话 id 以及当前表层中的 user、assistant 和 steering(中途引导)消息传给可选的 `ctx.sessionQuery` 服务,返回至多 20 个会话/snippet 对和一个提示细化查询的标志位,并转发载体请求信号以支持取消。部署若未挂载该服务,或索引/查询操作失败,都会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 + `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 0c7107a1f9..c84a4c00ff 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f81f5c9be8..a5055ef1d8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -11,6 +11,7 @@ import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { @@ -20,8 +21,8 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, - WorkspaceId, WorkspaceView, + ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSearchItem, + SessionSummary, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' @@ -37,9 +38,17 @@ import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 +/** Product contract: sidebar search returns one bounded page and no cursor. */ +const SESSION_SEARCH_LIMIT = 20 + /** Surface message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) +/** Read live abort state across awaits without treating it as synchronously immutable. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + /** * Message-boundary pagination: count maxMessages surface messages backwards from * the window tail; the cut is the starting seq of the oldest message group @@ -561,6 +570,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return operation } + /** + * Build the session.list baseline shared by listing and search visibility. + * Attached sessions come from memory; servable cold sessions merge from + * persistence, and the final order is newest-first. + */ + async function listVisibleSessionSummaries(): Promise { + const items = ctx.sessions.list().map((session) => { + const agent = ctx.agents.get(session.id) + return summarize(session, agent?.status === 'running') + }) + const attached = new Set(items.map(item => item.sessionId)) + const persistence = ctx.get('sessionPersistence') + if (persistence !== undefined) { + const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) + items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta)))) + } + items.sort((a, b) => b.updatedAt - a.updatedAt) + return items + } + return { sessions: { // Attached sessions summarize from memory; persisted-but-unattached (cold) @@ -568,18 +597,61 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Legacy logs without a cwd (pre-project stance) are not served — every // session now records its project at create time. async list(request) { - const items = ctx.sessions.list().map((session) => { - const agent = ctx.agents.get(session.id) - return summarize(session, agent?.status === 'running') + return ok(request, { items: await listVisibleSessionSummaries() }) + }, + + async search(request, signal) { + const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, { + code: 'cancelled', + message: 'session search was aborted', + details: {}, }) - const attached = new Set(items.map(item => item.sessionId)) - const persistence = ctx.get('sessionPersistence') - if (persistence !== undefined) { - const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) - items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta)))) + if (isAborted(signal)) return cancelled() + const sessionQuery = ctx.get('sessionQuery') + if (sessionQuery === undefined) { + return err(request, { + code: 'internal', + message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query', + details: {}, + }) + } + try { + const visible = await listVisibleSessionSummaries() + if (isAborted(signal)) return cancelled() + if (visible.length === 0) return ok(request, { items: [], hasMore: false }) + const visibleIds = new Set(visible.map(item => item.sessionId)) + const page = await sessionQuery.searchSessions({ + query: request.payload.query, + sessionFilters: [{ kind: 'id', values: [...visibleIds] }], + eventFilters: [ + { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, + { kind: 'surface', values: ['current'] }, + ], + limit: SESSION_SEARCH_LIMIT, + }, { signal }) + if (isAborted(signal)) return cancelled() + // The id filter is the authorization boundary. Re-check the provider + // projection before emitting it so a backend regression cannot leak + // a session that `session.list` withheld. + const authorized = page.items.filter(hit => visibleIds.has(hit.header.id)) + return ok(request, { + items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({ + sessionId: hit.header.id, + snippet: hit.bestMatch.snippet, + })), + hasMore: page.nextCursor !== undefined || authorized.length > SESSION_SEARCH_LIMIT, + }) + } catch (error: unknown) { + if ( + isAborted(signal) + || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') + ) return cancelled() + return err(request, { + code: 'internal', + message: `session search failed: ${String(error)}`, + details: {}, + }) } - items.sort((a, b) => b.updatedAt - a.updatedAt) - return ok(request, { items }) }, async create(request) { diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 537b2744ef..4425e05ecf 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -25,7 +25,7 @@ export interface ApiProxy { } // ---- Domain interfaces and payload entities ---- -export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts' +export type { HistoryEntry, SessionSearchItem, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index abe992584c..8f136de494 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -18,6 +18,7 @@ import type { RpcResponse } from './rpc.ts' */ export interface RpcMethodMap { 'session.list': SessionsApi['list'] + 'session.search': SessionsApi['search'] 'session.create': SessionsApi['create'] 'session.history': SessionsApi['history'] 'session.prompt': SessionsApi['prompt'] diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 12ebd4182d..4db9ac32c4 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -9,7 +9,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import type { HistoryEntry, SessionSummary } from './sessions.ts' +import type { HistoryEntry, SessionSearchItem, SessionSummary } from './sessions.ts' import type { ToolEventView } from './events.ts' import type { WorkspaceId } from './workspace.ts' @@ -54,6 +54,29 @@ export const sessionListValueSchema = z.object({ items: z.array(sessionSummarySchema), }) satisfies z.ZodType>> +/** Fixed wire bound for one interactive sidebar query. */ +const SESSION_SEARCH_QUERY_MAX_CHARS = 500 +/** Product response bound validated independently by every client carrier. */ +const SESSION_SEARCH_RESULT_LIMIT = 20 + +/** session.search request payload. */ +export const sessionSearchRequestSchema = z.object({ + query: z.string().trim().min(1).max(SESSION_SEARCH_QUERY_MAX_CHARS) + .refine(query => !query.includes('\0'), { message: 'search query must not contain NUL' }), +}) satisfies z.ZodType>> + +/** One session.search result. */ +export const sessionSearchItemSchema = z.object({ + sessionId: sessionIdSchema, + snippet: z.string(), +}) satisfies z.ZodType> + +/** session.search response value. */ +export const sessionSearchValueSchema = z.object({ + items: z.array(sessionSearchItemSchema).max(SESSION_SEARCH_RESULT_LIMIT), + hasMore: z.boolean(), +}) satisfies z.ZodType>> + /** session.create request payload (at most one of workspaceId / cwd). */ export const sessionCreateRequestSchema = z.object({ workspaceId: workspaceIdSchema.optional(), diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 2552b5d5a3..7f62eea93f 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -53,11 +53,28 @@ export interface SessionSummary { cwd?: string } +/** One session-content search result; display metadata stays owned by `session.list`. */ +export interface SessionSearchItem { + sessionId: SessionId + /** Plain-text excerpt around the strongest matching visible message. */ + snippet: string +} + /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ list(request: RpcRequest<{ cursor?: string }>): Promise> + /** + * Searches the current user/assistant/steering message surface across + * sessions visible to `list`. Results contain at most 20 sessions and carry + * no continuation cursor; `hasMore` asks the client to refine the query. + */ + search( + request: RpcRequest<{ query: string }>, + signal: AbortSignal, + ): Promise> + /** * Creates a real session and its idle agent. At most one of `workspaceId` / * `cwd` is accepted; an omitted project uses the Host cwd. A caller may diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0424ba7a4f..b967e5b80c 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -20,6 +20,7 @@ import { sessionHistoryValueSchema, sessionListValueSchema, sessionPromptValueSchema, + sessionSearchValueSchema, } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, @@ -48,6 +49,7 @@ import { skillListValueSchema } from '../api/skills.schema.ts' export interface IApiClient { sessions: { list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise>> + search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise>> create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise>> history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> @@ -83,6 +85,7 @@ export interface IApiClient { */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { 'session.list': sessionListValueSchema, + 'session.search': sessionSearchValueSchema, 'session.create': sessionCreateValueSchema, 'session.history': sessionHistoryValueSchema, 'session.prompt': sessionPromptValueSchema, @@ -271,6 +274,7 @@ export abstract class AbstractApiClient implements IApiClient { readonly sessions: IApiClient['sessions'] = { list: (payload, signal) => this.callUnary('session.list', payload, signal), + search: (payload, signal) => this.callUnary('session.search', payload, signal), create: (payload, signal) => this.callUnary('session.create', payload, signal), history: (payload, signal) => this.callUnary('session.history', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index b79980d63e..4115f77fa0 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -20,6 +20,7 @@ import { sessionHistoryRequestSchema, sessionListRequestSchema, sessionPromptRequestSchema, + sessionSearchRequestSchema, } from '../api/sessions.schema.ts' import { hostDescribeRequestSchema } from '../api/host.schema.ts' import { @@ -38,7 +39,8 @@ import { skillListRequestSchema } from '../api/skills.schema.ts' * Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation * documented on Wire); the dispatch point carries the one Wire→exact cast. * Every invoke receives the carrier Request's signal; methods whose contract - * declares a signal parameter (command.execute) forward it, the rest ignore it. + * declares a signal parameter (session.search and command.execute) forward it, + * the rest ignore it. */ type UnaryRoutes = { [K in keyof RpcMethodMap]: { @@ -49,6 +51,7 @@ type UnaryRoutes = { const UNARY_ROUTES: UnaryRoutes = { 'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) }, + 'session.search': { schema: sessionSearchRequestSchema, invoke: (api, r, signal) => api.sessions.search(r, signal) }, 'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) }, 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts new file mode 100644 index 0000000000..5f8060a696 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -0,0 +1,237 @@ +/** + * Host session.search projection: list-equivalent visibility, fixed message + * filters and result bound, cancellation mapping, and unavailable/failure + * behavior. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { + SessionQueryError, + type SessionSearchHit, + type SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' +import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +const sid = (value: string): SessionId => value as SessionId +const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } + +function request(query: string): RpcRequest<{ query: string }> { + return { rpcId: RpcId(`search-${query}`), payload: { query } } +} + +function header(id: string, cwd: string | null = '/project'): SessionHeader { + return { + version: 0, + id: sid(id), + createdAt: 100, + ...(cwd === null ? {} : { cwd }), + } +} + +function hit(id: string, index = 0): SessionSearchHit { + const session = header(id) + return { + header: session, + live: true, + persisted: false, + bestMatch: { + sessionId: session.id, + seq: index, + type: 'user/message', + time: 200 + index, + surface: 'current', + snippet: `match ${index}`, + }, + } +} + +async function baseContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + return ctx +} + +describe('session.search', () => { + it('searches only list-visible ids and current conversation-message events', async () => { + const ctx = await baseContext() + const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') }) + live.append('user/message', { + content: [{ type: 'text', text: 'live text' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const cold = header('cold', '/cold') + const legacy = header('legacy', null) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([cold, legacy]), + locate: () => undefined, + } as never) + + const searchSessions = vi.fn(( + _request: SessionSearchRequest, + _exec?: { signal?: AbortSignal }, + ) => Promise.resolve({ + items: [ + { + header: legacy, + live: false, + persisted: true, + bestMatch: { + sessionId: legacy.id, + seq: 3, + type: 'user/message' as const, + time: 190, + surface: 'current' as const, + snippet: 'must remain hidden', + }, + }, + { + header: cold, + live: false, + persisted: true, + bestMatch: { + sessionId: cold.id, + seq: 4, + type: 'assistant/message' as const, + time: 200, + surface: 'current' as const, + snippet: 'the matching answer', + }, + }, + ], + nextCursor: 'more' as never, + })) + ctx.provide('sessionQuery', { searchSessions } as never) + const api = createApiProxy(ctx, defaults) + const signal = new AbortController().signal + + const response = await api.sessions.search(request('matching answer'), signal) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'cold', snippet: 'the matching answer' }], + hasMore: true, + }, + }) + expect(searchSessions).toHaveBeenCalledOnce() + const [query, exec] = searchSessions.mock.calls[0] as unknown as [ + SessionSearchRequest, + { signal: AbortSignal }, + ] + expect(query).toEqual({ + query: 'matching answer', + sessionFilters: [{ kind: 'id', values: ['live', 'cold'] }], + eventFilters: [ + { + kind: 'type', + values: ['user/message', 'assistant/message', 'steering/message'], + }, + { kind: 'surface', values: ['current'] }, + ], + limit: 20, + }) + expect(exec.signal).toBe(signal) + }) + + it('returns an empty page without invoking the index when no session is visible', async () => { + const ctx = await baseContext() + const searchSessions = vi.fn() + ctx.provide('sessionQuery', { searchSessions } as never) + const api = createApiProxy(ctx, defaults) + + const response = await api.sessions.search( + request('anything'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { items: [], hasMore: false }, + }) + expect(searchSessions).not.toHaveBeenCalled() + }) + + it('enforces the 20-item Host boundary even if a provider overproduces', async () => { + const ctx = await baseContext() + const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of items) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + ctx.provide('sessionQuery', { + searchSessions: () => Promise.resolve({ items }), + } as never) + const response = await createApiProxy(ctx, defaults).sessions.search( + request('match'), + new AbortController().signal, + ) + + expect(response.result).toMatchObject({ + ok: true, + value: { hasMore: true }, + }) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.items).toHaveLength(20) + expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19') + }) + + it('maps missing composition, query cancellation, and provider failure', async () => { + const missingCtx = await baseContext() + missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) + const missingApi = createApiProxy(missingCtx, defaults) + const preAborted = new AbortController() + preAborted.abort() + const cancelledBeforeLookup = await missingApi.sessions.search( + request('cancel-before-lookup'), + preAborted.signal, + ) + expect(cancelledBeforeLookup.result).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + + const missing = await missingApi.sessions.search( + request('needle'), + new AbortController().signal, + ) + expect(missing.result.ok).toBe(false) + if (missing.result.ok) throw new Error('unreachable') + expect(missing.result.error.code).toBe('internal') + expect(missing.result.error.message).toContain('does not mount') + + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED') + const searchSessions = vi.fn() + .mockRejectedValueOnce(aborted) + .mockRejectedValueOnce(new Error('database unavailable')) + ctx.provide('sessionQuery', { searchSessions } as never) + const api = createApiProxy(ctx, defaults) + + const cancelled = await api.sessions.search( + request('first'), + new AbortController().signal, + ) + expect(cancelled.result).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + + const failed = await api.sessions.search( + request('second'), + new AbortController().signal, + ) + expect(failed.result.ok).toBe(false) + if (failed.result.ok) throw new Error('unreachable') + expect(failed.result.error.code).toBe('internal') + expect(failed.result.error.message).toContain('database unavailable') + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index a9a5eac9ba..fe583b7351 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -29,6 +29,7 @@ function scriptedApi(overrides: { return { sessions: { list: r => ok(r, { items: [] }), + search: r => ok(r, { items: [], hasMore: false }), create: r => ok(r, { sessionId: sid('s-new') }), history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { accepted: true as const }), @@ -76,6 +77,30 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } }) }) + it('round-trips a trimmed session search query and its bounded result metadata', async () => { + let seen: RpcRequest<{ query: string }> | undefined + const api = scriptedApi({ + sessions: { + search: (request) => { + seen = request + return ok(request, { + items: [{ sessionId: sid('s1'), snippet: 'matching message text' }], + hasMore: true, + }) + }, + }, + }) + const response = await client(api).sessions.search({ query: ' message text ' }) + expect(seen?.payload).toEqual({ query: 'message text' }) + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 's1', snippet: 'matching message text' }], + hasMore: true, + }, + }) + }) + it('routes workspace rename and insertSessionBefore through the wire', async () => { const api = scriptedApi() const c = client(api) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index e8d65d2a62..1d9bd00122 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -21,6 +21,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra if (overrides.crashOn === 'session.list') throw new Error('impl crashed') return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } }, + async search(request, signal) { + if (request.payload.query === 'hang') { + if (!signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return { + rpcId: request.rpcId, + result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } }, + } + } + return { + rpcId: request.rpcId, + result: { + ok: true, + value: { items: [{ sessionId: 's1' as never, snippet: 'fixture match' }], hasMore: false }, + }, + } + }, async create(request) { return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } } }, @@ -124,6 +144,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => { it('covers create/prompt/cancel/describe passthrough', async () => { const c = client() + expect((await c.sessions.search({ query: 'fixture' })).result).toEqual({ + ok: true, + value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false }, + }) expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) @@ -155,6 +179,29 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(parsed.rpcId).toBe('r-sig') expect(parsed.result.error?.code).toBe('cancelled') }) + + it('propagates the carrier Request signal into session.search', async () => { + const handler = toFetchHandler(fakeApi()) + const controller = new AbortController() + const body = JSON.stringify({ + type: 'client-request', + rpcId: 'r-search-sig', + method: 'session.search', + payload: { query: 'hang' }, + }) + const pending = handler.fetch(new Request( + 'http://x/api/session.search', + { method: 'POST', body, signal: controller.signal }, + )) + controller.abort() + const response = await pending + const parsed = await response.json() as { + rpcId: string + result: { error?: { code: string } } + } + expect(parsed.rpcId).toBe('r-search-sig') + expect(parsed.result.error?.code).toBe('cancelled') + }) }) describe('handler carrier-layer statuses', () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 02ca8dec22..1261959260 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -9,7 +9,7 @@ import { contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema, sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema, sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema, - sessionPromptValueSchema, sessionSummarySchema, + sessionPromptValueSchema, sessionSearchRequestSchema, sessionSearchValueSchema, sessionSummarySchema, } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { @@ -121,6 +121,28 @@ describe('sessions domain schemas', () => { expect(sessionListRequestSchema.parse({})).toEqual({}) expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c') expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([]) + expect(sessionSearchRequestSchema.parse({ query: ' exact phrase ' })).toEqual({ query: 'exact phrase' }) + expect(() => sessionSearchRequestSchema.parse({ query: ' ' })).toThrow() + expect(() => sessionSearchRequestSchema.parse({ query: 'bad\0query' })).toThrow(/NUL/) + expect(() => sessionSearchRequestSchema.parse({ query: 'x'.repeat(501) })).toThrow() + expect(sessionSearchValueSchema.parse({ + items: [{ sessionId: 's1', snippet: 'matching text' }], + hasMore: true, + })).toEqual({ + items: [{ sessionId: 's1', snippet: 'matching text' }], + hasMore: true, + }) + expect(() => sessionSearchValueSchema.parse({ + items: [{ sessionId: '', snippet: 'matching text' }], + hasMore: false, + })).toThrow() + expect(() => sessionSearchValueSchema.parse({ + items: Array.from( + { length: 21 }, + (_, index) => ({ sessionId: `s${index}`, snippet: 'matching text' }), + ), + hasMore: true, + })).toThrow() expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w') // The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects. expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1') diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index f5aabb1cf8..6327c74f5e 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../session-persistence/session-persistence" }, + { + "path": "../../session-query/session-query" + }, { "path": "../../session-title/session-title" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..80dfdc102a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,12 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../packages/session-query/session-query + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../packages/session-query/session-query-sqlite '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title @@ -2559,6 +2565,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../session-query/session-query '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session-title/session-title From 222096e3cf50ea1fbe182f129caa96e942fa540d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 12:27:11 +0800 Subject: [PATCH 02/33] fix(web): validate search hit provenance (round 2) --- .../lifecycle-chrome/hero.expected.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 12 ++++-- .../apiproxy/tests/api-proxy-search.spec.ts | 37 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index f280e35fc6..39fc49c023 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -8,9 +8,9 @@ - img - button "Create workspace": - img -- button "Search sessions": +- button "搜索会话": - img -- textbox "Search name, keywords..." +- textbox "搜索名称或关键词…" - tree "Sessions": - treeitem "workspace 1 session" [expanded]: - img diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a5055ef1d8..d6456acc67 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -630,10 +630,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro limit: SESSION_SEARCH_LIMIT, }, { signal }) if (isAborted(signal)) return cancelled() - // The id filter is the authorization boundary. Re-check the provider - // projection before emitting it so a backend regression cannot leak - // a session that `session.list` withheld. - const authorized = page.items.filter(hit => visibleIds.has(hit.header.id)) + // The filters are the authorization boundary. Re-check the complete + // provider provenance before emitting its snippet so a backend + // regression cannot pair an allowed header with excluded content. + const authorized = page.items.filter(hit => + visibleIds.has(hit.header.id) + && hit.bestMatch.sessionId === hit.header.id + && hit.bestMatch.surface === 'current' + && MESSAGE_TYPES.has(hit.bestMatch.type)) return ok(request, { items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({ sessionId: hit.header.id, diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 5f8060a696..fcb29fdbcc 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -160,6 +160,43 @@ describe('session.search', () => { expect(searchSessions).not.toHaveBeenCalled() }) + it('rejects snippets whose provider provenance violates the Host filters', async () => { + const ctx = await baseContext() + const visible = hit('visible') + ctx.sessions.create(visible.header.id, { meta: visible.header }) + const withBestMatch = ( + index: number, + bestMatch: Partial, + ): SessionSearchHit => { + const base = hit('visible', index) + return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } } + } + ctx.provide('sessionQuery', { + searchSessions: () => Promise.resolve({ + items: [ + withBestMatch(0, { sessionId: sid('hidden') }), + withBestMatch(1, { surface: 'shadowed' }), + withBestMatch(2, { type: 'tool/result' }), + withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }), + ], + nextCursor: 'more', + }), + } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('match'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'visible', snippet: 'allowed snippet' }], + hasMore: true, + }, + }) + }) + it('enforces the 20-item Host boundary even if a provider overproduces', async () => { const ctx = await baseContext() const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) From 31215687f9e0a8eb390c1be26cb738e3555d01ef Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 12:42:46 +0800 Subject: [PATCH 03/33] fix(web): honor search ownership and cancellation --- .../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 +-- apps/cli/README.i18n.yaml | 4 +-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 8 +++-- packages/host/apiproxy/src/api-proxy.ts | 31 ++++++++++++++--- .../apiproxy/tests/api-proxy-search.spec.ts | 34 +++++++++++++++++++ 9 files changed, 75 insertions(+), 18 deletions(-) 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 31d6b377af..1aa4dd3109 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: 3cc44ba3652415e9fa32ce67bceefc822c41059b -2026-07-27-web-session-search.zh.md: 2b6ea7a60e1b051757852ff331c0b93c48bd2b57 +2026-07-27-web-session-search.md: 421922d25bc61c1813a515e8439af0c7956b2efb +2026-07-27-web-session-search.zh.md: 04035e74fae718dbf65b294b4293d368ecc89cf7 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 3cc44ba365..421922d25b 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 @@ -10,9 +10,9 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri ## Decision -The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at `.sessions/session-query.db`. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence. +The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at a process-owned `.sessions/session-query-.db` path. Process scoping preserves the SQLite backend's single-owner contract when multiple CLI or Web processes run from the same directory. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence. -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`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. +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`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work, including the persistence listing and bounded batches of cold-session metadata stats that build the visibility set. A missing query service or an 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. 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. 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 2b6ea7a60e..04035e74fa 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 @@ -10,9 +10,9 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 ## 决策 -Web 与 headless 共用的组合会在 `.sessions/session-query.db` 挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。 +Web 与 headless 共用的组合会在由单一进程拥有的 `.sessions/session-query-.db` 路径挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。当多个 CLI 或 Web 进程从同一目录运行时,进程级隔离可维持 SQLite 后端的单一所有者契约。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举和构建可见集合时分批受限执行的冷会话元数据 stat。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index e78ab7b8ad..5e6fb13cd9 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: bb3f4ee98700e4644535d1d3c05d29a9a558275d -README.zh.md: 44edea0f5b36598cfda1b61914da0b6622b973d6 +README.md: c1d76e42a6788f48c4dd01bbf71281e1081a411c +README.zh.md: 69bd88ca8fc7086c94017ac7d25ebd55e8585427 diff --git a/apps/cli/README.md b/apps/cli/README.md index bb3f4ee987..c1d76e42a6 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable SQLite content index at `.sessions/session-query.db`. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable, process-owned SQLite content index at `.sessions/session-query-.db`. The process-specific path preserves the backend's single-owner contract across parallel invocations. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 44edea0f5b..69bd88ca8f 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且在 `.sessions/session-query.db` 挂载一个可丢弃的 SQLite 内容索引。该索引在启动时不经扫描即打开,并在首次会话搜索时惰性对账已更改的实时日志和持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且在 `.sessions/session-query-.db` 挂载一个可丢弃、由单一进程拥有的 SQLite 内容索引。该进程专属路径可在并行调用时维持后端的单一所有者契约。该索引在启动时不经扫描即打开,并在首次会话搜索时惰性对账已更改的实时日志和持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 4adb5048b8..be9303344d 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,12 +89,14 @@ config: root: './.sessions' -# Lazy content index for session.search. Opening the database at boot does -# not scan logs; the first search reconciles changed live/persisted sessions. +# Lazy, process-owned content index for session.search. Opening the database +# at boot does not scan logs; the first search reconciles changed +# live/persisted sessions. The pid prevents concurrent dsh processes in the +# same cwd from sharing one unsupported SQLite owner path. - id: session-query-sqlite name: '@deepseek-ai/dsh-session-query-sqlite' config: - path: './.sessions/session-query.db' + path: !!js "'./.sessions/session-query-' + process.pid + '.db'" - id: storage name: '@deepseek-ai/dsh-storage' diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d6456acc67..367479e698 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,6 +41,9 @@ const DEFAULT_MAX_MESSAGES = 50 /** Product contract: sidebar search returns one bounded page and no cursor. */ const SESSION_SEARCH_LIMIT = 20 +/** Bound cold-log stat fan-out so an aborted search stops launching new work. */ +const COLD_SUMMARY_BATCH_SIZE = 16 + /** Surface message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) @@ -170,15 +173,22 @@ function summarize(session: Session, running: boolean): SessionSummary { * updatedAt is the log file's mtime; backends without a per-session file * (locate() undefined) fall back to the header's createdAt. */ -async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise { +async function summarizeCold( + persistence: SessionPersistence, + meta: SessionHeader, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() let updatedAt = meta.createdAt const location = persistence.locate(meta) + signal?.throwIfAborted() if (location !== undefined) { try { updatedAt = (await stat(location.path)).mtimeMs } catch { // The log vanished between list() and stat() (concurrent cleanup); createdAt stands in. } + signal?.throwIfAborted() } return { sessionId: meta.id, @@ -575,16 +585,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * Attached sessions come from memory; servable cold sessions merge from * persistence, and the final order is newest-first. */ - async function listVisibleSessionSummaries(): Promise { + async function listVisibleSessionSummaries(signal?: AbortSignal): Promise { + signal?.throwIfAborted() const items = ctx.sessions.list().map((session) => { const agent = ctx.agents.get(session.id) return summarize(session, agent?.status === 'running') }) + signal?.throwIfAborted() const attached = new Set(items.map(item => item.sessionId)) const persistence = ctx.get('sessionPersistence') if (persistence !== undefined) { - const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) - items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta)))) + const cold = (await persistence.list(signal)) + .filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) + signal?.throwIfAborted() + for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) { + signal?.throwIfAborted() + const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE) + items.push(...await Promise.all( + batch.map(meta => summarizeCold(persistence, meta, signal)), + )) + signal?.throwIfAborted() + } } items.sort((a, b) => b.updatedAt - a.updatedAt) return items @@ -616,7 +637,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } try { - const visible = await listVisibleSessionSummaries() + const visible = await listVisibleSessionSummaries(signal) if (isAborted(signal)) return cancelled() if (visible.length === 0) return ok(request, { items: [], hasMore: false }) const visibleIds = new Set(visible.map(item => item.sessionId)) diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index fcb29fdbcc..99f40a5949 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -220,6 +220,40 @@ describe('session.search', () => { expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19') }) + it('propagates cancellation through visible-session collection and stops cold-summary work', async () => { + const ctx = await baseContext() + const controller = new AbortController() + const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`)) + const list = vi.fn((signal?: AbortSignal) => { + expect(signal).toBe(controller.signal) + return Promise.resolve(cold) + }) + let locateCalls = 0 + ctx.provide('sessionPersistence', { + list, + locate: () => { + locateCalls++ + controller.abort() + return undefined + }, + } as never) + const searchSessions = vi.fn() + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('cancel-during-visibility'), + controller.signal, + ) + + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + expect(list).toHaveBeenCalledOnce() + expect(locateCalls).toBe(1) + expect(searchSessions).not.toHaveBeenCalled() + }) + it('maps missing composition, query cancellation, and provider failure', async () => { const missingCtx = await baseContext() missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) From bd42204e53d38af428c7c5985207dc91503a36bf Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 12:51:47 +0800 Subject: [PATCH 04/33] fix(cli): keep search index ephemeral --- .../feature/2026-07-27-web-session-search.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-27-web-session-search.md | 2 +- .../feature/2026-07-27-web-session-search.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 9 ++++----- apps/web/tests/scaffold.ts | 2 +- docs/config-catalog.md | 6 +++--- packages/session-query/session-query-sqlite/src/index.ts | 6 +++--- 10 files changed, 19 insertions(+), 20 deletions(-) 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 1aa4dd3109..1dfb037886 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: 421922d25bc61c1813a515e8439af0c7956b2efb -2026-07-27-web-session-search.zh.md: 04035e74fae718dbf65b294b4293d368ecc89cf7 +2026-07-27-web-session-search.md: 8791d02249cc310e768712b3967dcfead3a950e0 +2026-07-27-web-session-search.zh.md: 737f91fbe2c00ac5aa75fb6c30b8b22f80b0854f 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 421922d25b..8791d02249 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 @@ -10,7 +10,7 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri ## Decision -The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) at a process-owned `.sessions/session-query-.db` path. Process scoping preserves the SQLite backend's single-owner contract when multiple CLI or Web processes run from the same directory. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is a disposable derived index, separate from canonical JSONL persistence. +The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with an in-memory database. Each service instance owns one connection-private index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty; the first content query of each invocation lazily reconciles live and persisted sessions. It remains a disposable derived index, separate from canonical JSONL persistence. 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`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work, including the persistence listing and bounded batches of cold-session metadata stats that build the visibility set. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. 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 04035e74fa..737f91fbe2 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 @@ -10,7 +10,7 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 ## 决策 -Web 与 headless 共用的组合会在由单一进程拥有的 `.sessions/session-query-.db` 路径挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。当多个 CLI 或 Web 进程从同一目录运行时,进程级隔离可维持 SQLite 后端的单一所有者契约。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。 +Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。每个服务实例都独占一个连接私有索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动;每次调用的首次内容查询会惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举和构建可见集合时分批受限执行的冷会话元数据 stat。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 5e6fb13cd9..9a5db218c0 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: c1d76e42a6788f48c4dd01bbf71281e1081a411c -README.zh.md: 69bd88ca8fc7086c94017ac7d25ebd55e8585427 +README.md: 0c4ff8d36a89e234512f42d130774fe3717968b9 +README.zh.md: 7e116ecb32061060816f27279a5d3b555a584f6b diff --git a/apps/cli/README.md b/apps/cli/README.md index c1d76e42a6..0c4ff8d36a 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable, process-owned SQLite content index at `.sessions/session-query-.db`. The process-specific path preserves the backend's single-owner contract across parallel invocations. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content index. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind. The index starts empty and lazily reconciles live and persisted logs on the first session search of each invocation. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 69bd88ca8f..7e116ecb32 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且在 `.sessions/session-query-.db` 挂载一个可丢弃、由单一进程拥有的 SQLite 内容索引。该进程专属路径可在并行调用时维持后端的单一所有者契约。该索引在启动时不经扫描即打开,并在首次会话搜索时惰性对账已更改的实时日志和持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件。该索引从空状态启动,并在每次调用的首次会话搜索时惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index be9303344d..35979b274f 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,14 +89,13 @@ config: root: './.sessions' -# Lazy, process-owned content index for session.search. Opening the database -# at boot does not scan logs; the first search reconciles changed -# live/persisted sessions. The pid prevents concurrent dsh processes in the -# same cwd from sharing one unsupported SQLite owner path. +# Lazy, service-owned content index for session.search. The in-memory database +# cannot be shared across processes or leak derived files across invocations; +# the first search reconciles changed live/persisted sessions for this boot. - id: session-query-sqlite name: '@deepseek-ai/dsh-session-query-sqlite' config: - path: !!js "'./.sessions/session-query-' + process.pid + '.db'" + path: ':memory:' - id: storage name: '@deepseek-ai/dsh-storage' diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f809081d3d..eb61619de0 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -163,7 +163,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Mon, 27 Jul 2026 13:05:59 +0800 Subject: [PATCH 05/33] fix(web): support large search corpora (round 4) --- .../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 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 69 ++++++++++++------- .../apiproxy/tests/api-proxy-search.spec.ts | 50 ++++++++++++-- 8 files changed, 96 insertions(+), 39 deletions(-) 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 1dfb037886..721f0f9483 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: 8791d02249cc310e768712b3967dcfead3a950e0 -2026-07-27-web-session-search.zh.md: 737f91fbe2c00ac5aa75fb6c30b8b22f80b0854f +2026-07-27-web-session-search.md: e3219f865aa3f13cdb7e806570ef6134dd5bd448 +2026-07-27-web-session-search.zh.md: 27d0efee5d218c2e737fb7378d3fd71c6b13e4ce 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 8791d02249..e3219f865a 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 @@ -12,7 +12,7 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with an in-memory database. Each service instance owns one connection-private index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty; the first content query of each invocation lazily reconciles live and persisted sessions. It remains a disposable derived index, separate from canonical JSONL persistence. -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`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work, including the persistence listing and bounded batches of cold-session metadata stats that build the visibility set. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. +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`, `assistant/message`, and `steering/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. 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. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider page. A missing query service or an 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. 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. 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 737f91fbe2..27d0efee5d 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 @@ -12,7 +12,7 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。每个服务实例都独占一个连接私有索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动;每次调用的首次内容查询会惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举和构建可见集合时分批受限执行的冷会话元数据 stat。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一页提供方搜索。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 7d323dbeea..5079e05965 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: deb1073c2ff9e7a533e595ee3f5537e649660e5b -README.zh.md: e3c521d5f6a09414d087e3fb142852e6d3eb0cb7 +README.md: b9f0fcd8506afda733774868d78fe6e851b4fe2a +README.zh.md: c57990b029b8d1e143396bb13718dbed165e2b47 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index deb1073c2f..b9f0fcd850 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,7 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. -`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway passes only those session ids and current-surface user, assistant, and steering messages to the optional `ctx.sessionQuery` service, returns at most 20 session/snippet pairs plus a refine-query bit, and forwards the carrier request signal for cancellation. A deployment without the service, or a failed index/query operation, returns an `internal` business error so clients can retain metadata-only matches. +`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, pages that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. The carrier request signal cancels persistence listing, cold-summary collection, and every search page. A deployment without the service, or a failed index/query operation, returns an `internal` business error so clients can retain metadata-only matches. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3c521d5f6..c57990b029 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关仅将这些会话 id 以及当前表层中的 user、assistant 和 steering(中途引导)消息传给可选的 `ctx.sessionQuery` 服务,返回至多 20 个会话/snippet 对和一个提示细化查询的标志位,并转发载体请求信号以支持取消。部署若未挂载该服务,或索引/查询操作失败,都会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 +`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,对该结果流分页,直到获得至多 20 个可见会话/snippet 对及一个前瞻项,并在返回前依据从列表推导的授权集合重新校验每个命中。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一页搜索。部署若未挂载该服务,或索引/查询操作失败,都会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 367479e698..c9329feea8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -11,7 +11,7 @@ import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { @@ -641,30 +641,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (isAborted(signal)) return cancelled() if (visible.length === 0) return ok(request, { items: [], hasMore: false }) const visibleIds = new Set(visible.map(item => item.sessionId)) - const page = await sessionQuery.searchSessions({ - query: request.payload.query, - sessionFilters: [{ kind: 'id', values: [...visibleIds] }], - eventFilters: [ - { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, - { kind: 'surface', values: ['current'] }, - ], - limit: SESSION_SEARCH_LIMIT, - }, { signal }) - if (isAborted(signal)) return cancelled() - // The filters are the authorization boundary. Re-check the complete - // provider provenance before emitting its snippet so a backend - // regression cannot pair an allowed header with excluded content. - const authorized = page.items.filter(hit => - visibleIds.has(hit.header.id) - && hit.bestMatch.sessionId === hit.header.id - && hit.bestMatch.surface === 'current' - && MESSAGE_TYPES.has(hit.bestMatch.type)) + const authorized: SessionSearchItem[] = [] + const acceptedIds = new Set() + const seenCursors = new Set() + let cursor: SessionSearchCursor | undefined + while (authorized.length <= SESSION_SEARCH_LIMIT) { + if (isAborted(signal)) return cancelled() + const page = await sessionQuery.searchSessions({ + query: request.payload.query, + eventFilters: [ + { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, + { kind: 'surface', values: ['current'] }, + ], + limit: SESSION_SEARCH_LIMIT, + ...cursor === undefined ? {} : { cursor }, + }, { signal }) + if (isAborted(signal)) return cancelled() + // Host visibility is the authorization boundary. Consume the + // provider's globally ranked stream rather than binding every + // visible id into one SQLite statement, then re-check complete + // provenance before emitting any snippet. + for (const hit of page.items) { + if ( + !visibleIds.has(hit.header.id) + || hit.bestMatch.sessionId !== hit.header.id + || hit.bestMatch.surface !== 'current' + || !MESSAGE_TYPES.has(hit.bestMatch.type) + || acceptedIds.has(hit.header.id) + ) continue + acceptedIds.add(hit.header.id) + authorized.push({ + sessionId: hit.header.id, + snippet: hit.bestMatch.snippet, + }) + if (authorized.length > SESSION_SEARCH_LIMIT) break + } + if (authorized.length > SESSION_SEARCH_LIMIT || page.nextCursor === undefined) break + if (seenCursors.has(page.nextCursor)) { + throw new Error('session search provider repeated a continuation cursor') + } + seenCursors.add(page.nextCursor) + cursor = page.nextCursor + } return ok(request, { - items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({ - sessionId: hit.header.id, - snippet: hit.bestMatch.snippet, - })), - hasMore: page.nextCursor !== undefined || authorized.length > SESSION_SEARCH_LIMIT, + items: authorized.slice(0, SESSION_SEARCH_LIMIT), + hasMore: authorized.length > SESSION_SEARCH_LIMIT, }) } catch (error: unknown) { if ( diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 99f40a5949..5138398fe3 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -107,7 +107,6 @@ describe('session.search', () => { }, }, ], - nextCursor: 'more' as never, })) ctx.provide('sessionQuery', { searchSessions } as never) const api = createApiProxy(ctx, defaults) @@ -119,7 +118,7 @@ describe('session.search', () => { ok: true, value: { items: [{ sessionId: 'cold', snippet: 'the matching answer' }], - hasMore: true, + hasMore: false, }, }) expect(searchSessions).toHaveBeenCalledOnce() @@ -129,7 +128,6 @@ describe('session.search', () => { ] expect(query).toEqual({ query: 'matching answer', - sessionFilters: [{ kind: 'id', values: ['live', 'cold'] }], eventFilters: [ { kind: 'type', @@ -179,7 +177,6 @@ describe('session.search', () => { withBestMatch(2, { type: 'tool/result' }), withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }), ], - nextCursor: 'more', }), } as never) @@ -192,19 +189,25 @@ describe('session.search', () => { ok: true, value: { items: [{ sessionId: 'visible', snippet: 'allowed snippet' }], - hasMore: true, + hasMore: false, }, }) }) - it('enforces the 20-item Host boundary even if a provider overproduces', async () => { + it('pages the globally ranked stream until the 20-item Host boundary is known', async () => { const ctx = await baseContext() const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) for (const item of items) { ctx.sessions.create(item.header.id, { meta: item.header }) } + const searchSessions = vi.fn() + .mockResolvedValueOnce({ + items: [hit('hidden-ranked-first'), ...items.slice(0, 19)], + nextCursor: 'page-2', + }) + .mockResolvedValueOnce({ items: items.slice(19) }) ctx.provide('sessionQuery', { - searchSessions: () => Promise.resolve({ items }), + searchSessions, } as never) const response = await createApiProxy(ctx, defaults).sessions.search( request('match'), @@ -218,6 +221,39 @@ describe('session.search', () => { if (!response.result.ok) throw new Error('unreachable') expect(response.result.value.items).toHaveLength(20) expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19') + expect(searchSessions).toHaveBeenCalledTimes(2) + expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' }) + }) + + it('keeps visibility sets above SQLite variable limits out of provider bindings', async () => { + const ctx = await baseContext() + const cold = Array.from( + { length: 32_751 }, + (_, index) => header(`cold-${index}`, `/cold-${index}`), + ) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve(cold), + locate: () => undefined, + } as never) + const searchSessions = vi.fn(() => Promise.resolve({ + items: [hit('cold-32750')], + })) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('large corpus'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'cold-32750', snippet: 'match 0' }], + hasMore: false, + }, + }) + expect(searchSessions).toHaveBeenCalledOnce() + expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters') }) it('propagates cancellation through visible-session collection and stops cold-summary work', async () => { From 30503c3b0308752d9f915e8af631d76a1fed754d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 13:07:23 +0800 Subject: [PATCH 06/33] test(web): type large-corpus search mock (round 5) --- packages/host/apiproxy/tests/api-proxy-search.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 5138398fe3..05e33fb08f 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -235,7 +235,7 @@ describe('session.search', () => { list: () => Promise.resolve(cold), locate: () => undefined, } as never) - const searchSessions = vi.fn(() => Promise.resolve({ + const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({ items: [hit('cold-32750')], })) ctx.provide('sessionQuery', { searchSessions } as never) From a8c28be1ba345bda3e8a90847ca992edcda0668e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 13:18:05 +0800 Subject: [PATCH 07/33] fix(web): bound search provider work (round 6) --- .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 8 +- .../2026-07-27-web-session-search.zh.md | 8 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 15 +++ .../apiproxy/tests/api-proxy-search.spec.ts | 122 ++++++++++++++++++ 8 files changed, 151 insertions(+), 14 deletions(-) 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 721f0f9483..c7ce4dcdd1 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: e3219f865aa3f13cdb7e806570ef6134dd5bd448 -2026-07-27-web-session-search.zh.md: 27d0efee5d218c2e737fb7378d3fd71c6b13e4ce +2026-07-27-web-session-search.md: 2e82c9cb0d453ce6e863f8c436cb793979d17a64 +2026-07-27-web-session-search.zh.md: 010b29f80098bce518354992f23cd499e63e0b04 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 e3219f865a..2e82c9cb0d 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 @@ -12,7 +12,7 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with an in-memory database. Each service instance owns one connection-private index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty; the first content query of each invocation lazily reconciles live and persisted sessions. It remains a disposable derived index, separate from canonical JSONL persistence. -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`, `assistant/message`, and `steering/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. 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. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider page. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. +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`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. 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. The Host makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that 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 page. A missing query service or an 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. 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. @@ -20,7 +20,7 @@ Content matching inherits the SQLite backend's normalized literal token/phrase s ## Failure and visibility contract -Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and the query receives only ids from that baseline. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits. +Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and only provider hits whose ids occur in that baseline can leave the Host. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits. While the first or a later content request is pending, the UI keeps immediate metadata matches and shows a history-search status. If the backend fails, the same rows remain and a warning explains that content search is unavailable. Zero merged rows produce an explicit empty state. More than 20 candidate rows produce a refine-query hint. @@ -35,8 +35,8 @@ While the first or a later content request is pending, the UI keeps immediate me Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search. -The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. +The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective provider stream that has not produced enough authorized results within its first 2,000 hits takes the metadata-only failure path instead of consuming unbounded work. ## Testing -Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. +Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, provider page and item budgets, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. 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 27d0efee5d..010b29f800 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 @@ -12,7 +12,7 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。每个服务实例都独占一个连接私有索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动;每次调用的首次内容查询会惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一页提供方搜索。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一页提供方搜索。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 @@ -20,7 +20,7 @@ Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/ds ## 故障与可见性契约 -搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;查询只会接收这条基线提供的 id。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。 +搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;只有 id 位于这条基线中的提供方命中才能离开宿主。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。 首个或后续内容请求仍在处理期间,UI 会保留即时元数据匹配结果,并显示历史搜索状态。如果后端失败,这些行会保持不变,并显示警告说明内容搜索不可用。合并后没有任何行时,界面会显示明确的空状态。候选行超过 20 条时,界面会提示用户缩小查询范围。 @@ -35,8 +35,8 @@ Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/ds 无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。 -首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。 +首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差的提供方结果流在前 2,000 个命中内仍未产生足够多的已授权结果,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。 ## 测试 -宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、取消与故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 +宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、提供方页数和单页命中数预算、游标与跨页去重行为、后续页取消及故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5079e05965..b4c9f9f9fb 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: b9f0fcd8506afda733774868d78fe6e851b4fe2a -README.zh.md: c57990b029b8d1e143396bb13718dbed165e2b47 +README.md: 2f062ba9b927ab62518523731d39fd7c52e07c8d +README.zh.md: 72ff415f793b4bdb2068a8240ea42baab79984dc diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index b9f0fcd850..2f062ba9b9 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,7 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. -`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, pages that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. The carrier request signal cancels persistence listing, cold-summary collection, and every search page. A deployment without the service, or a failed index/query operation, returns an `internal` business error so clients can retain metadata-only matches. +`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches in pages capped at 20 hits, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. It makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that budget fails closed as an `internal` business error. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. The carrier request signal cancels persistence listing, cold-summary collection, and every search page. A deployment without the service, or a failed index/query operation, also returns an `internal` business error so clients can retain metadata-only matches. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c57990b029..72ff415f79 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,对该结果流分页,直到获得至多 20 个可见会话/snippet 对及一个前瞻项,并在返回前依据从列表推导的授权集合重新校验每个命中。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一页搜索。部署若未挂载该服务,或索引/查询操作失败,都会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 +`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,每页至多 20 个命中,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一页搜索。部署若未挂载该服务,或索引/查询操作失败,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c9329feea8..e94f71fd3f 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,6 +41,9 @@ const DEFAULT_MAX_MESSAGES = 50 /** Product contract: sidebar search returns one bounded page and no cursor. */ const SESSION_SEARCH_LIMIT = 20 +/** Provider work budget: at most 100 pages × 20 hits = 2,000 inspected hits. */ +const SESSION_SEARCH_PROVIDER_PAGE_LIMIT = 100 + /** Bound cold-log stat fan-out so an aborted search stops launching new work. */ const COLD_SUMMARY_BATCH_SIZE = 16 @@ -645,8 +648,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const acceptedIds = new Set() const seenCursors = new Set() let cursor: SessionSearchCursor | undefined + let providerPageCount = 0 while (authorized.length <= SESSION_SEARCH_LIMIT) { if (isAborted(signal)) return cancelled() + if (providerPageCount >= SESSION_SEARCH_PROVIDER_PAGE_LIMIT) { + throw new Error( + `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_PAGE_LIMIT}-page work budget`, + ) + } + providerPageCount++ const page = await sessionQuery.searchSessions({ query: request.payload.query, eventFilters: [ @@ -657,6 +667,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ...cursor === undefined ? {} : { cursor }, }, { signal }) if (isAborted(signal)) return cancelled() + if (page.items.length > SESSION_SEARCH_LIMIT) { + throw new Error( + `session search provider returned ${page.items.length} items; maximum is ${SESSION_SEARCH_LIMIT}`, + ) + } // Host visibility is the authorization boundary. Consume the // provider's globally ranked stream rather than binding every // visible id into one SQLite statement, then re-check complete diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 05e33fb08f..840535ba2d 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -225,6 +225,128 @@ describe('session.search', () => { expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' }) }) + it('fails closed after 100 provider pages with distinct continuation cursors', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + let pageNumber = 0 + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + pageNumber++ + expect(providerRequest.limit).toBe(20) + return Promise.resolve({ + items: [], + nextCursor: `page-${pageNumber}`, + }) + }) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('endless-pages'), + new AbortController().signal, + ) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error).toMatchObject({ code: 'internal' }) + expect(response.result.error.message).toContain('100-page work budget') + expect(searchSessions).toHaveBeenCalledTimes(100) + }) + + it('rejects an oversized provider page before iterating its items', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const oversized = new Array(21) + const iterate = vi.fn(() => oversized.values()) + Object.defineProperty(oversized, Symbol.iterator, { value: iterate }) + const searchSessions = vi.fn(() => Promise.resolve({ items: oversized })) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('oversized-page'), + new AbortController().signal, + ) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error).toMatchObject({ code: 'internal' }) + expect(response.result.error.message).toContain('returned 21 items; maximum is 20') + expect(iterate).not.toHaveBeenCalled() + }) + + it('fails closed when the provider repeats a continuation cursor', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) + .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('repeated-cursor'), + new AbortController().signal, + ) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error).toMatchObject({ code: 'internal' }) + expect(response.result.error.message).toContain('repeated a continuation cursor') + expect(searchSessions).toHaveBeenCalledTimes(2) + }) + + it('does not count duplicate session ids toward the result or lookahead boundary', async () => { + const ctx = await baseContext() + const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of items) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' }) + .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' }) + .mockResolvedValueOnce({ items: items.slice(20) }) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('duplicate-pages'), + new AbortController().signal, + ) + + expect(response.result).toMatchObject({ + ok: true, + value: { hasMore: true }, + }) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.items.map(item => item.sessionId)).toEqual( + items.slice(0, 20).map(item => item.header.id), + ) + expect(searchSessions).toHaveBeenCalledTimes(3) + }) + + it('cancels on a continuation page and passes the carrier signal to both calls', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const controller = new AbortController() + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: [], nextCursor: 'page-2' }) + .mockImplementationOnce(() => { + controller.abort() + return Promise.resolve({ items: [] }) + }) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('cancel-continuation'), + controller.signal, + ) + + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + expect(searchSessions).toHaveBeenCalledTimes(2) + for (const call of searchSessions.mock.calls) { + expect(call[1]).toEqual({ signal: controller.signal }) + } + }) + it('keeps visibility sets above SQLite variable limits out of provider bindings', async () => { const ctx = await baseContext() const cold = Array.from( From 40b68cd8d55a7ae57de4d3b87c3afc0dfa849b5d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 13:24:39 +0800 Subject: [PATCH 08/33] fix(web): harden paged search protocol (round 7) --- packages/host/apiproxy/src/api-proxy.ts | 29 ++++++---- .../apiproxy/tests/api-proxy-search.spec.ts | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e94f71fd3f..8126dd432d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -667,16 +667,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ...cursor === undefined ? {} : { cursor }, }, { signal }) if (isAborted(signal)) return cancelled() - if (page.items.length > SESSION_SEARCH_LIMIT) { + const providerItemCount = page.items.length + if (providerItemCount > SESSION_SEARCH_LIMIT) { throw new Error( - `session search provider returned ${page.items.length} items; maximum is ${SESSION_SEARCH_LIMIT}`, + `session search provider returned ${providerItemCount} items; maximum is ${SESSION_SEARCH_LIMIT}`, ) } // Host visibility is the authorization boundary. Consume the // provider's globally ranked stream rather than binding every // visible id into one SQLite statement, then re-check complete - // provenance before emitting any snippet. - for (const hit of page.items) { + // provenance before emitting any snippet. Inspect exactly the + // declared array entries so a custom iterator cannot overproduce. + for (let itemIndex = 0; itemIndex < providerItemCount; itemIndex++) { + const hit = page.items[itemIndex] + if (hit === undefined) { + throw new Error(`session search provider omitted item at index ${itemIndex}`) + } + if (authorized.length > SESSION_SEARCH_LIMIT) continue if ( !visibleIds.has(hit.header.id) || hit.bestMatch.sessionId !== hit.header.id @@ -689,14 +696,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionId: hit.header.id, snippet: hit.bestMatch.snippet, }) - if (authorized.length > SESSION_SEARCH_LIMIT) break } - if (authorized.length > SESSION_SEARCH_LIMIT || page.nextCursor === undefined) break - if (seenCursors.has(page.nextCursor)) { - throw new Error('session search provider repeated a continuation cursor') + const nextCursor = page.nextCursor + if (nextCursor !== undefined) { + if (seenCursors.has(nextCursor)) { + throw new Error('session search provider repeated a continuation cursor') + } + seenCursors.add(nextCursor) } - seenCursors.add(page.nextCursor) - cursor = page.nextCursor + if (authorized.length > SESSION_SEARCH_LIMIT || nextCursor === undefined) break + cursor = nextCursor } return ok(request, { items: authorized.slice(0, SESSION_SEARCH_LIMIT), diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 840535ba2d..734ddfc499 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -272,6 +272,33 @@ describe('session.search', () => { expect(iterate).not.toHaveBeenCalled() }) + it('inspects only numerically stored items when a compliant page overrides iteration', async () => { + const ctx = await baseContext() + const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of visible) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const stored = visible.slice(0, 1) + const iterate = vi.fn(() => visible.values()) + Object.defineProperty(stored, Symbol.iterator, { value: iterate }) + const searchSessions = vi.fn(() => Promise.resolve({ items: stored })) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('custom-iterator'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'visible-0', snippet: 'match 0' }], + hasMore: false, + }, + }) + expect(iterate).not.toHaveBeenCalled() + }) + it('fails closed when the provider repeats a continuation cursor', async () => { const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) @@ -292,6 +319,32 @@ describe('session.search', () => { expect(searchSessions).toHaveBeenCalledTimes(2) }) + it('validates a repeated cursor before accepting the authorized lookahead', async () => { + const ctx = await baseContext() + const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of items) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' }) + .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' }) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('repeated-lookahead-cursor'), + new AbortController().signal, + ) + + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + expect(response.result).not.toHaveProperty('value') + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.message).toContain('repeated a continuation cursor') + expect(searchSessions).toHaveBeenCalledTimes(2) + }) + it('does not count duplicate session ids toward the result or lookahead boundary', async () => { const ctx = await baseContext() const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) From ba2925c7041bac2976c362c1a5bec379b2c0af3f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 14:02:35 +0800 Subject: [PATCH 09/33] fix(web): converge search runtime boundaries (round 8) --- .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 8 +- .../2026-07-27-web-session-search.zh.md | 8 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/cordis.yml | 7 +- docs/config-catalog.md | 7 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 76 +++++-- .../apiproxy/tests/api-proxy-search.spec.ts | 202 +++++++++++++++++- .../session-query-sqlite/README.i18n.yaml | 6 +- .../session-query-sqlite/README.md | 3 + .../session-query-sqlite/README.zh.md | 3 + .../session-query-sqlite/src/index.ts | 30 ++- .../session-query-sqlite/src/schema.ts | 3 +- .../tests/lazy-open.compat.spec.ts | 45 ++++ .../session-query-sqlite/tests/sqlite.spec.ts | 96 ++++++++- scripts/run-gates.ts | 5 + 21 files changed, 469 insertions(+), 54 deletions(-) create mode 100644 packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts 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 c7ce4dcdd1..6124b31fb4 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: 2e82c9cb0d453ce6e863f8c436cb793979d17a64 -2026-07-27-web-session-search.zh.md: 010b29f80098bce518354992f23cd499e63e0b04 +2026-07-27-web-session-search.md: 8992fdf046c1256ab61278cf5189ba56df8b4ecd +2026-07-27-web-session-search.zh.md: 764e9f3363ae321c55e401cc52b35dcba790a0b4 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 2e82c9cb0d..8992fdf046 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 @@ -10,9 +10,9 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri ## Decision -The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with an in-memory database. Each service instance owns one connection-private index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty; the first content query of each invocation lazily reconciles live and persisted sessions. It remains a disposable derived index, separate from canonical JSONL persistence. +The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with `openAt: first-search` and an in-memory database. The service is ACTIVE at boot, while its `node:sqlite` module and connection-private handle open only on the first content query. This keeps Node 22 startup output free of SQLite's experimental warning before search is used without promising to suppress the warning when search first imports the module. Each service instance owns its index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty and lazily reconciles live and persisted sessions on that first query. It remains a disposable derived index, separate from canonical JSONL persistence. -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`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. 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. The Host makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that 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 page. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store. +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`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, event type, and snippet type are revalidated before its snippet leaves the Host, and emitted snippets contain at most 240 Unicode code points. 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. Those retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); an oversized page, 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 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. 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. @@ -35,8 +35,8 @@ While the first or a later content request is pending, the UI keeps immediate me Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search. -The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective provider stream that has not produced enough authorized results within its first 2,000 hits takes the metadata-only failure path instead of consuming unbounded work. +The first content query can take longer because it imports and opens SQLite before paying lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective or repeatedly stale provider attempt that does not complete within 100 calls takes the metadata-only failure path instead of consuming unbounded work. ## Testing -Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, provider page and item budgets, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. +Host tests pin request validation, visible-session filtering, event/surface filters, result and snippet bounds, the shared provider-call budget, stale-generation restarts, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; a Node 22 compatibility subprocess pins warning-free mount and disposal before the first search. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains. 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 010b29f800..764e9f3363 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 @@ -10,9 +10,9 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 ## 决策 -Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。每个服务实例都独占一个连接私有索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动;每次调用的首次内容查询会惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 +Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一页提供方搜索。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface、事件类型和 snippet 类型都会经过重新校验,其 snippet 才能离开宿主,且发出的 snippet 最多包含 240 个 Unicode 码点。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。这些重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 @@ -35,8 +35,8 @@ Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/ds 无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。 -首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差的提供方结果流在前 2,000 个命中内仍未产生足够多的已授权结果,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。 +首次内容查询可能耗时更长,因为它要先导入并打开 SQLite,再承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差或反复陈旧的提供方尝试未能在 100 次调用内完成,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。 ## 测试 -宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、提供方页数和单页命中数预算、游标与跨页去重行为、后续页取消及故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 +宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享提供方调用预算、陈旧世代重启、游标与跨页去重行为、后续页取消及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;一个 Node 22 兼容性子进程将首次搜索前无警告挂载与处置固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 9a5db218c0..4bf441aa6a 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 0c4ff8d36a89e234512f42d130774fe3717968b9 -README.zh.md: 7e116ecb32061060816f27279a5d3b555a584f6b +README.md: c3a3cbbdd578b0705a7e6c6d62c52dcd9cf6fa60 +README.zh.md: b755a82917e9472b6e788667a4f3387abce3397b diff --git a/apps/cli/README.md b/apps/cli/README.md index 0c4ff8d36a..c3a3cbbdd5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content index. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind. The index starts empty and lazily reconciles live and persisted logs on the first session search of each invocation. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 7e116ecb32..b755a82917 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件。该索引从空状态启动,并在每次调用的首次会话搜索时惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 35979b274f..96e4716c4d 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,13 +89,14 @@ config: root: './.sessions' -# Lazy, service-owned content index for session.search. The in-memory database -# cannot be shared across processes or leak derived files across invocations; -# the first search reconciles changed live/persisted sessions for this boot. +# The service activates at boot, while first-search defers the node:sqlite +# import and in-memory handle so Node 22 startup stays quiet until content +# search actually uses SQLite. That search then reconciles this boot's sources. - id: session-query-sqlite name: '@deepseek-ai/dsh-session-query-sqlite' config: path: ':memory:' + openAt: first-search - id: storage name: '@deepseek-ai/dsh-storage' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a69d5f1978..5b7415aec3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1057,6 +1057,8 @@ export interface Config extends SessionQueryConfig { * POSIX filesystems; existing modes are preserved. */ path: string + /** Open the SQLite module and handle at service activation or the first search. Defaults to `startup`. */ + openAt?: OpenAt /** SQLite journal mode. Defaults to `wal`. */ journalMode?: JournalMode /** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */ @@ -1069,13 +1071,16 @@ export interface Config extends SessionQueryConfig { persistedInspectConcurrency?: number } +/** SQLite module/handle opening phase. */ +export type OpenAt = 'startup' | 'first-search' + /** Supported SQLite journal modes. */ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:79`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index b4c9f9f9fb..39a555e071 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 2f062ba9b927ab62518523731d39fd7c52e07c8d -README.zh.md: 72ff415f793b4bdb2068a8240ea42baab79984dc +README.md: e61de41a14294b8c1601e5be8cab19fdf780916d +README.zh.md: 7e49ad49aaa9356412f90b37237b06ce65776e1c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 2f062ba9b9..e61de41a14 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,9 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. -`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches in pages capped at 20 hits, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. It makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that budget fails closed as an `internal` business error. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. The carrier request signal cancels persistence listing, cold-summary collection, and every search page. A deployment without the service, or a failed index/query operation, also returns an `internal` business error so clients can retain metadata-only matches. +`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches in pages capped at 20 hits, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Returned snippets contain at most 240 Unicode code points; a malformed non-string provider snippet fails closed instead of crossing the RPC boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. + +A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot. Stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier request signal cancels persistence listing, cold-summary collection, and every search call, including a stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an `internal` business error so clients can retain metadata-only matches. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 72ff415f79..7e49ad49aa 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,9 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,每页至多 20 个命中,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一页搜索。部署若未挂载该服务,或索引/查询操作失败,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 +`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,每页至多 20 个命中,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。返回的 snippet 最多包含 240 个 Unicode 码点;如果提供方返回格式错误的非字符串 snippet,系统会直接失败,而不会让它越过 RPC 边界。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 + +陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始。陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8126dd432d..f22539fefb 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,8 +41,11 @@ const DEFAULT_MAX_MESSAGES = 50 /** Product contract: sidebar search returns one bounded page and no cursor. */ const SESSION_SEARCH_LIMIT = 20 -/** Provider work budget: at most 100 pages × 20 hits = 2,000 inspected hits. */ -const SESSION_SEARCH_PROVIDER_PAGE_LIMIT = 100 +/** Provider work budget: at most 100 calls and 2,000 inspected hits. */ +const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 + +/** Product contract: snippets contain at most 240 Unicode code points. */ +const SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT = 240 /** Bound cold-log stat fan-out so an aborted search stops launching new work. */ const COLD_SUMMARY_BATCH_SIZE = 16 @@ -55,6 +58,28 @@ function isAborted(signal: AbortSignal): boolean { return signal.aborted } +/** Copy at most the product-visible code-point prefix without splitting a surrogate pair. */ +function boundedSessionSearchSnippet(value: unknown): string { + if (typeof value !== 'string') { + throw new Error('session search provider returned a non-string snippet') + } + let end = 0 + for ( + let count = 0; + count < SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT && end < value.length; + count++ + ) { + const first = value.charCodeAt(end) + const hasSurrogatePair = first >= 0xD800 + && first <= 0xDBFF + && end + 1 < value.length + && value.charCodeAt(end + 1) >= 0xDC00 + && value.charCodeAt(end + 1) <= 0xDFFF + end += hasSurrogatePair ? 2 : 1 + } + return end === value.length ? value : value.slice(0, end) +} + /** * Message-boundary pagination: count maxMessages surface messages backwards from * the window tail; the cut is the starting seq of the oldest message group @@ -648,24 +673,42 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const acceptedIds = new Set() const seenCursors = new Set() let cursor: SessionSearchCursor | undefined - let providerPageCount = 0 + let providerCallCount = 0 while (authorized.length <= SESSION_SEARCH_LIMIT) { if (isAborted(signal)) return cancelled() - if (providerPageCount >= SESSION_SEARCH_PROVIDER_PAGE_LIMIT) { + if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) { throw new Error( - `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_PAGE_LIMIT}-page work budget`, + `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`, ) } - providerPageCount++ - const page = await sessionQuery.searchSessions({ - query: request.payload.query, - eventFilters: [ - { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, - { kind: 'surface', values: ['current'] }, - ], - limit: SESSION_SEARCH_LIMIT, - ...cursor === undefined ? {} : { cursor }, - }, { signal }) + providerCallCount++ + const requestedCursor = cursor + let page + try { + page = await sessionQuery.searchSessions({ + query: request.payload.query, + eventFilters: [ + { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] }, + { kind: 'surface', values: ['current'] }, + ], + limit: SESSION_SEARCH_LIMIT, + ...requestedCursor === undefined ? {} : { cursor: requestedCursor }, + }, { signal }) + } catch (error: unknown) { + if (isAborted(signal)) return cancelled() + if ( + requestedCursor !== undefined + && error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_STALE_CURSOR' + ) { + authorized.length = 0 + acceptedIds.clear() + seenCursors.clear() + cursor = undefined + continue + } + throw error + } if (isAborted(signal)) return cancelled() const providerItemCount = page.items.length if (providerItemCount > SESSION_SEARCH_LIMIT) { @@ -691,10 +734,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro || !MESSAGE_TYPES.has(hit.bestMatch.type) || acceptedIds.has(hit.header.id) ) continue + const snippet = boundedSessionSearchSnippet(hit.bestMatch.snippet) acceptedIds.add(hit.header.id) authorized.push({ sessionId: hit.header.id, - snippet: hit.bestMatch.snippet, + snippet, }) } const nextCursor = page.nextCursor diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 734ddfc499..0ab2d97792 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -225,7 +225,7 @@ describe('session.search', () => { expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' }) }) - it('fails closed after 100 provider pages with distinct continuation cursors', async () => { + it('fails closed after 100 provider calls with distinct continuation cursors', async () => { const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) let pageNumber = 0 @@ -247,10 +247,153 @@ describe('session.search', () => { expect(response.result.ok).toBe(false) if (response.result.ok) throw new Error('unreachable') expect(response.result.error).toMatchObject({ code: 'internal' }) - expect(response.result.error.message).toContain('100-page work budget') + expect(response.result.error.message).toContain('100-call work budget') expect(searchSessions).toHaveBeenCalledTimes(100) }) + it('restarts a stale continuation from one fresh generation and keeps the visibility snapshot', async () => { + const ctx = await baseContext() + const oldOnly = hit('old-only', 0) + const shared = hit('shared', 1) + const freshFirst = hit('fresh-first', 2) + const freshLast = hit('fresh-last', 3) + for (const item of [oldOnly, shared, freshFirst, freshLast]) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const late = hit('late-visible', 4) + const stale = new SessionQueryError( + 'provider generation changed', + 'SESSION_QUERY_STALE_CURSOR', + ) + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + switch (searchSessions.mock.calls.length) { + case 1: + expect(providerRequest).not.toHaveProperty('cursor') + return Promise.resolve({ + items: [oldOnly, shared], + nextCursor: 'old-cursor', + }) + case 2: + expect(providerRequest.cursor).toBe('old-cursor') + ctx.sessions.create(late.header.id, { meta: late.header }) + return Promise.reject(stale) + case 3: + expect(providerRequest).not.toHaveProperty('cursor') + return Promise.resolve({ + items: [freshFirst, shared], + nextCursor: 'old-cursor', + }) + case 4: + expect(providerRequest.cursor).toBe('old-cursor') + return Promise.resolve({ items: [freshLast, late] }) + default: + return Promise.reject(new Error('unexpected provider call')) + } + }) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('stale-restart'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [ + { sessionId: 'fresh-first', snippet: 'match 2' }, + { sessionId: 'shared', snippet: 'match 1' }, + { sessionId: 'fresh-last', snippet: 'match 3' }, + ], + hasMore: false, + }, + }) + expect(searchSessions).toHaveBeenCalledTimes(4) + }) + + it('counts continuous stale restarts against the 100-call budget', async () => { + const ctx = await baseContext() + const partial = hit('partial') + ctx.sessions.create(partial.header.id, { meta: partial.header }) + const stale = new SessionQueryError( + 'provider generation changed', + 'SESSION_QUERY_STALE_CURSOR', + ) + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + if (searchSessions.mock.calls.length > 100) { + return Promise.reject(new Error('provider was called after the shared budget')) + } + if (providerRequest.cursor !== undefined) return Promise.reject(stale) + return Promise.resolve({ + items: [partial], + nextCursor: `cursor-${searchSessions.mock.calls.length}`, + }) + }) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('stale-churn'), + new AbortController().signal, + ) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('internal') + expect(response.result.error.message).toContain('100-call work budget') + expect(response.result).not.toHaveProperty('value') + expect(searchSessions).toHaveBeenCalledTimes(100) + }) + + it('gives abort priority over a coincident stale continuation failure', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const controller = new AbortController() + const stale = new SessionQueryError( + 'provider generation changed', + 'SESSION_QUERY_STALE_CURSOR', + ) + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: [], nextCursor: 'stale-cursor' }) + .mockImplementationOnce(() => { + controller.abort() + return Promise.reject(stale) + }) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('abort-stale'), + controller.signal, + ) + + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + expect(searchSessions).toHaveBeenCalledTimes(2) + }) + + it('does not retry a stale first-page failure', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const searchSessions = vi.fn(() => Promise.reject(new SessionQueryError( + 'provider generation changed before paging', + 'SESSION_QUERY_STALE_CURSOR', + ))) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('first-page-stale'), + new AbortController().signal, + ) + + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + expect(response.result).not.toHaveProperty('value') + expect(searchSessions).toHaveBeenCalledOnce() + }) + it('rejects an oversized provider page before iterating its items', async () => { const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) @@ -272,6 +415,61 @@ describe('session.search', () => { expect(iterate).not.toHaveBeenCalled() }) + it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => { + const ctx = await baseContext() + const visible = hit('visible') + ctx.sessions.create(visible.header.id, { meta: visible.header }) + const expected = `${'x'.repeat(239)}😀` + const overlong = { + ...visible, + bestMatch: { + ...visible.bestMatch, + snippet: `${expected}${'y'.repeat(10_000)}`, + }, + } + ctx.provide('sessionQuery', { + searchSessions: () => Promise.resolve({ items: [overlong] }), + } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('bounded-snippet'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'visible', snippet: expected }], + hasMore: false, + }, + }) + }) + + it('fails closed when the provider returns a non-string snippet', async () => { + const ctx = await baseContext() + const visible = hit('visible') + ctx.sessions.create(visible.header.id, { meta: visible.header }) + ctx.provide('sessionQuery', { + searchSessions: () => Promise.resolve({ + items: [{ + ...visible, + bestMatch: { ...visible.bestMatch, snippet: 42 }, + }], + }), + } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('malformed-snippet'), + new AbortController().signal, + ) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('internal') + expect(response.result.error.message).toContain('non-string snippet') + expect(response.result).not.toHaveProperty('value') + }) + it('inspects only numerically stored items when a compliant page overrides iteration', async () => { const ctx = await baseContext() const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) diff --git a/packages/session-query/session-query-sqlite/README.i18n.yaml b/packages/session-query/session-query-sqlite/README.i18n.yaml index 9c5f95f8ce..88e88cc15d 100644 --- a/packages/session-query/session-query-sqlite/README.i18n.yaml +++ b/packages/session-query/session-query-sqlite/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: ceffb3ac25bc8b5252d6cc40cd6389839dfce1e2 -README.zh.md: 4e11ae9c9b8012045a7f3bab5d5c45724e553303 +# pnpm run verify-translation-pairing --write packages/session-query/session-query-sqlite/README.md +README.md: 4bf4d979f2d2954cd6280c80bf7f5988d8121fd1 +README.zh.md: afa45ad364a92e0268cf40c90dd61b163be0e18f diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index ceffb3ac25..4bf4d979f2 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -16,6 +16,8 @@ All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by def The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares source-qualified lightweight durable snapshot revisions, non-mutatingly inspects only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Session queries never invoke the persistence backend's crash-repairing `load()`; an owner attaching during inspection cannot mutate its log, and the stable-observation retry makes the result live-preferred. The TEMP live row still records persisted availability, and the durable base refreshes after that live owner detaches. Repeated queries and an unchanged same-store reopen perform no full durable-log inspection; switching stores, or observing new, changed, deleted, or externally load-repaired sources, reconciles on the next stable observation. Source or transaction failure commits nothing, and the next search retries. +`openAt: startup` is the default: service activation imports `node:sqlite`, opens the handle, and fails before publication when the index is invalid. `openAt: first-search` publishes the service as ACTIVE without importing the SQLite module or opening a handle; the first concurrent searches share one readiness promise, and disposal before any search opens nothing. This mode supports compositions that need clean Node 22 startup output by deferring SQLite's experimental warning until the first actual search; it does not suppress a warning at that point. An invalid database likewise fails the first search instead of service activation. + Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. The database is disposable but reset is guarded: every recognized schema version rejects unknown user tables before mutating journal mode, and only a recognized incompatible schema containing derived tables rebuilds in place. An unrelated or canonical database is refused. Never point `path` at the session-persistence database. On filesystems with POSIX modes, missing directories and databases are created owner-only (`0700` and `0600` before the process umask), and SQLite sidecars inherit the database mode; existing modes are preserved. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned. @@ -25,6 +27,7 @@ The database is disposable but reset is guarded: every recognized schema version | Key | Default | Contract | |---|---:|---| | `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. Missing filesystem paths are created owner-only on POSIX filesystems. | +| `openAt` | `startup` | `startup` opens before service activation completes; `first-search` defers the SQLite module and handle until search. | | `journalMode` | `wal` | `wal`, `delete`, `truncate`, or `persist`. | | `defaultLimit` | `20` | Page size when a request omits `limit`; at most `Number.MAX_SAFE_INTEGER - 1`. | | `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. | diff --git a/packages/session-query/session-query-sqlite/README.zh.md b/packages/session-query/session-query-sqlite/README.zh.md index 4e11ae9c9b..afa45ad364 100644 --- a/packages/session-query/session-query-sqlite/README.zh.md +++ b/packages/session-query/session-query-sqlite/README.zh.md @@ -16,6 +16,8 @@ 该服务需要 `ctx.sessions`,并动态观察可选的 `ctx.sessionPersistence`。一个串行化状态机比较来源限定的轻量持久化快照修订,以非变更方式只检查新日志或已更改日志,提取共享语义文档,以事务方式对账变更,然后运行查询。会话查询绝不会调用持久化后端会修复崩溃的 `load()`;检查期间附加的 owner 无法修改其日志,稳定观察重试使结果优先使用实时来源。TEMP 实时行仍会记录持久化可用性,而持久基库会在该实时 owner 脱离后刷新。重复查询和未变的同存储重新打开不会执行完整持久化日志检查;切换存储,或观察到新增、已更改、已删除或经外部 load 修复的来源时,会在下次稳定观察时对账。来源或事务失败不会提交任何内容,下一次搜索会重试。 +`openAt: startup` 是默认值:服务激活会导入 `node:sqlite` 并打开句柄;如果索引无效,则会在服务发布前失败。`openAt: first-search` 会将服务以 ACTIVE 状态发布,同时不导入 SQLite 模块也不打开句柄;首批并发搜索共享同一个就绪 promise,在任何搜索前处置服务时也不会导入模块或打开句柄。此模式通过把 SQLite 的实验性警告推迟到首次实际搜索,支持需要干净 Node 22 启动输出的组合;它不会抑制届时的警告。无效数据库同样会使首次搜索失败,而不是服务激活失败。 + 持久化 FTS 行位于专用派生数据库中。连接本地 TEMP 表保存实时行,这些行会遮蔽同一会话的持久化基库,并在实时 owner 消失后使其重新可见。卸载持久化会隐藏持久行,但不会丢弃缓存;重新挂载会对账缓存。关闭或重新打开数据库会删除全部实时覆盖层,但保留持久行。 该数据库可丢弃,但 reset 受到保护:每个已识别 schema 版本都会在修改 journal mode 前拒绝未知用户表;只有包含派生表的已识别不兼容 schema 才会原地重建。不相关数据库或规范数据库将被拒绝。绝不能将 `path` 指向 session-persistence 数据库。在具有 POSIX mode 的文件系统上,缺失的目录和数据库会以仅所有者可访问的方式创建(进程 umask 前为 `0700` 和 `0600`),SQLite sidecar 继承数据库 mode;现有 mode 保持不变。每个派生索引路径在一个进程中只能由一个服务拥有;不支持外部写入者或第二个进程,因为世代和 TEMP 遮蔽状态归连接所有。 @@ -25,6 +27,7 @@ | 键 | 默认值 | 契约 | |---|---:|---| | `path` | required | 专用派生索引 SQLite 路径;支持 `:memory:`。在 POSIX 文件系统上,缺失的文件系统路径会以仅所有者可访问的方式创建。 | +| `openAt` | `startup` | `startup` 会在服务激活完成前打开;`first-search` 把 SQLite 模块与句柄推迟到搜索时再加载和打开。 | | `journalMode` | `wal` | `wal`、`delete`、`truncate` 或 `persist`。 | | `defaultLimit` | `20` | 请求省略 `limit` 时的分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 | | `maxLimit` | `100` | 接受的最大请求分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 | diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 9f19108180..8e95196663 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -5,7 +5,7 @@ */ import { createHash, randomUUID } from 'node:crypto' -import { DatabaseSync } from 'node:sqlite' +import type { DatabaseSync } from 'node:sqlite' import { Context, Service, type Fiber } from 'cordis' import z from 'schemastery' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' @@ -72,6 +72,9 @@ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 // One transient source change gets a retry; repeated churn fails rather than monopolizing the queue. const STABLE_OBSERVATION_ATTEMPTS = 2 +/** SQLite module/handle opening phase. */ +export type OpenAt = 'startup' | 'first-search' + /** Combined session-query configuration backed by SQLite full-text search. */ export interface Config extends SessionQueryConfig { /** @@ -80,6 +83,8 @@ export interface Config extends SessionQueryConfig { * POSIX filesystems; existing modes are preserved. */ path: string + /** Open the SQLite module and handle at service activation or the first search. Defaults to `startup`. */ + openAt?: OpenAt /** SQLite journal mode. Defaults to `wal`. */ journalMode?: JournalMode /** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */ @@ -94,6 +99,7 @@ export interface Config extends SessionQueryConfig { interface ResolvedConfig { path: string + openAt: OpenAt journalMode: JournalMode defaultLimit: number maxLimit: number @@ -175,6 +181,7 @@ export class SessionQuerySqlite extends SessionQueryService { static Config: z = z.object({ path: z.string().required(), + openAt: z.union(['startup', 'first-search'] as const).default('startup'), journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), defaultLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT), maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT), @@ -191,7 +198,7 @@ export class SessionQuerySqlite extends SessionQueryService { readonly config: ResolvedConfig private readonly _instance = randomUUID() - private readonly _ready: Promise + private _ready: Promise | undefined private _db: DatabaseSync | undefined private _persistenceBinding: PersistenceBinding = { identity: Symbol() } private _lastPersistenceIdentity: symbol | undefined @@ -208,7 +215,6 @@ export class SessionQuerySqlite extends SessionQueryService { // register `ctx.sessionQuery`; keep that same validated value afterward. super(ctx, config = resolveConfig(config)) this.config = config as ResolvedConfig - this._ready = this._open() this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { const service = childCtx.sessionPersistence const binding = { identity: Symbol(), service } @@ -225,9 +231,9 @@ export class SessionQuerySqlite extends SessionQueryService { ctx.effect(() => async () => this.close(), 'sessionQuerySqlite.close') } - /** Open the index before Cordis publishes this combined service as active. */ + /** Open eagerly only when activation owns the configured readiness boundary. */ protected async [Service.init](): Promise { - await this._ensureReady(undefined) + if (this.config.openAt === 'startup') await this._ensureReady(undefined) } override async searchSessions( @@ -296,10 +302,12 @@ export class SessionQuerySqlite extends SessionQueryService { private async _close(): Promise { this._closed = true await this._tail - try { - await this._ready - } catch { - // Opening already closed a partially-created handle; disposal only waits. + if (this._ready !== undefined) { + try { + await this._ready + } catch { + // Opening already closed a partially-created handle; disposal only waits. + } } this._db?.close() this._db = undefined @@ -315,6 +323,7 @@ export class SessionQuerySqlite extends SessionQueryService { } private async _ensureReady(signal: AbortSignal | undefined): Promise { + this._ready ??= this._open() try { await waitWithAbort(this._ready, signal) } catch (error: unknown) { @@ -946,6 +955,7 @@ function invalidCursor(cause: unknown): SessionQueryError { function resolveConfig(config: Config): ResolvedConfig { const resolved: ResolvedConfig = { path: config.path, + openAt: config.openAt ?? 'startup', journalMode: config.journalMode ?? 'wal', defaultLimit: config.defaultLimit ?? SESSION_QUERY_SQLITE_DEFAULT_LIMIT, maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT, @@ -957,6 +967,8 @@ function resolveConfig(config: Config): ResolvedConfig { if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) { throw invalidConfig('path must not be blank') } + const openPhases: readonly string[] = ['startup', 'first-search'] + if (!openPhases.includes(resolved.openAt)) throw invalidConfig('openAt is not supported') assertPageLimit('defaultLimit', resolved.defaultLimit) assertPageLimit('maxLimit', resolved.maxLimit) assertPositiveInteger('snippetChars', resolved.snippetChars) diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 47f6374ba6..073b42d19e 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -1,6 +1,6 @@ /** SQLite schema for the disposable session full-text read model. */ -import { DatabaseSync } from 'node:sqlite' +import type { DatabaseSync } from 'node:sqlite' import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' @@ -49,6 +49,7 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode) await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) await createDatabaseFile(actual) } + const { DatabaseSync } = await import('node:sqlite') const db = new DatabaseSync(actual) try { const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } diff --git a/packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts b/packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts new file mode 100644 index 0000000000..eea18805fa --- /dev/null +++ b/packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts @@ -0,0 +1,45 @@ +/** + * Node 22 startup-output smoke for first-search SQLite opening. + * + * The isolated subprocess omits NODE_OPTIONS so warning suppression cannot + * hide a static node:sqlite import. + */ + +import { execFile } from 'node:child_process' +import { resolve } from 'node:path' +import { promisify } from 'node:util' +import { expect, it } from 'vitest' + +const execFileAsync = promisify(execFile) +const root = resolve(import.meta.dirname, '../../../..') + +it('mounts and disposes first-search mode without a SQLite experimental warning', async () => { + const script = ` + import { Context } from 'cordis' + import SessionStore from '@deepseek-ai/dsh-session' + import SessionQuerySqlite from './packages/session-query/session-query-sqlite/src/index.ts' + + const ctx = new Context() + const sessions = await ctx.plugin(SessionStore) + const search = await ctx.plugin(SessionQuerySqlite, { + path: ':memory:', + openAt: 'first-search', + }) + await search.dispose() + await sessions.dispose() + ` + const env = { ...process.env } + delete env.NODE_OPTIONS + const { stderr } = await execFileAsync(process.execPath, [ + '--import', + 'tsx', + '--input-type=module', + '--eval', + script, + ], { + cwd: root, + env, + }) + + expect(stderr).not.toMatch(/ExperimentalWarning: SQLite/) +}) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 2fdd0b1e89..9251f6c7f2 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -177,16 +177,19 @@ async function liveContext(config: ConstructorParameters { - it('defaults and validates persisted inspection concurrency through its Cordis config', async () => { + it('defaults and validates opening policy and persisted inspection concurrency through its Cordis config', async () => { const defaultCtx = await liveContext() + expect((defaultCtx.sessionQuery as SessionQuerySqlite).config.openAt).toBe('startup') expect((defaultCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency) .toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY) const configuredValue = 2 const configured = new SessionQuerySqlite.Config({ path: ':memory:', + openAt: 'first-search', persistedInspectConcurrency: configuredValue, }) + expect(configured.openAt).toBe('first-search') expect(configured.persistedInspectConcurrency).toBe(configuredValue) const configuredCtx = await liveContext(configured) expect((configuredCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency) @@ -198,6 +201,72 @@ describe('SQLite session search', () => { persistedInspectConcurrency, })).toThrow() } + expect(() => new SessionQuerySqlite.Config({ + path: ':memory:', + openAt: 'later' as never, + })).toThrow() + }) + + it('mounts and disposes first-search mode without opening its database', async () => { + const path = await temporaryPath('unopened.db') + const ctx = new Context() + await ctx.plugin(SessionStore) + const search = await ctx.plugin(SessionQuerySqlite, { + path, + openAt: 'first-search', + }) + + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + await search.dispose() + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('opens once on the first search and reuses readiness for later searches', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionQuerySqlite, { + path: ':memory:', + openAt: 'first-search', + }) + const service = ctx.sessionQuery as SessionQuerySqlite + const internals = service as unknown as { _open(): Promise } + const open = vi.spyOn(internals, '_open') + + await expect(service.searchSessions({ query: 'first' })).resolves.toEqual({ items: [] }) + await expect(service.searchSessions({ query: 'second' })).resolves.toEqual({ items: [] }) + + expect(open).toHaveBeenCalledOnce() + }) + + it('shares one readiness promise across concurrent first searches', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionQuerySqlite, { + path: ':memory:', + openAt: 'first-search', + }) + const service = ctx.sessionQuery as SessionQuerySqlite + const internals = service as unknown as { _open(): Promise } + const originalOpen = internals._open.bind(internals) + const release = Promise.withResolvers() + const started = Promise.withResolvers() + const open = vi.spyOn(internals, '_open').mockImplementation(async () => { + started.resolve(undefined) + await release.promise + await originalOpen() + }) + + const first = service.searchSessions({ query: 'first' }) + const second = service.searchSessions({ query: 'second' }) + await started.promise + expect(open).toHaveBeenCalledOnce() + release.resolve(undefined) + + await expect(Promise.all([first, second])).resolves.toEqual([ + { items: [] }, + { items: [] }, + ]) + expect(open).toHaveBeenCalledOnce() }) it('searches two-character Unicode61 tokens in live-only sessions', async () => { @@ -522,6 +591,7 @@ describe('SQLite session search', () => { { path: ':memory:', persistedInspectConcurrency: 0 }, { path: ':memory:', persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 }, { path: ':memory:', defaultLimit: 3, maxLimit: 2 }, + { path: ':memory:', openAt: 'later' }, { path: ':memory:', journalMode: 'memory' }, ]) { const direct = new Context() @@ -1238,6 +1308,30 @@ describe('SQLite schema, cancellation, and real persistence integration', () => } }) + it('defers an invalid database failure only in first-search mode', async () => { + const path = await temporaryPath('lazy-invalid.db') + const foreign = new DatabaseSync(path) + foreign.exec('CREATE TABLE canonical(value TEXT)') + foreign.close() + + const lazyCtx = new Context() + await lazyCtx.plugin(SessionStore) + const lazy = await lazyCtx.plugin(SessionQuerySqlite, { + path, + openAt: 'first-search', + }) + expect(lazyCtx.sessionQuery).toBeInstanceOf(SessionQuerySqlite) + await expect(lazyCtx.sessionQuery.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await lazy.dispose() + + const eagerCtx = new Context() + await eagerCtx.plugin(SessionStore) + await expect(eagerCtx.plugin(SessionQuerySqlite, { path })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + expect(eagerCtx.sessionQuery).toBeUndefined() + }) + it.each(['sessions', 'events'] as const)( 'forwards one exact reconciliation signal through both snapshot lists and persisted inspection for %s search', async (scope) => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ae11479274..7905fcfc18 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -261,6 +261,11 @@ function nodeCompatSmokeGates(): Gate[] { 'run', 'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts', ], { label: 'JSONL Zstandard smoke' }), + pnpmExec('session-query-lazy-open-smoke', [ + 'vitest', + 'run', + 'packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts', + ], { label: 'session-query lazy-open smoke' }), ] } From 0aa7f8c5cf6e682df87de52b24502b2036bc0761 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 27 Jul 2026 14:46:08 +0800 Subject: [PATCH 10/33] fix(web): converge session search boundaries (round 9) --- .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 6 +- .../2026-07-27-web-session-search.zh.md | 6 +- .../tests/lazy-search-startup.compat.spec.ts | 109 ++++++++++ apps/web/tests/navigation-panes.e2e.ts | 10 +- apps/web/tests/scaffold.ts | 2 +- .../lifecycle-chrome/hero.expected.md | 4 +- .../search-results.expected.md | 2 +- packages/client/connection/README.i18n.yaml | 6 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 117 ++++++++--- .../client/connection/tests/fixture.spec.ts | 17 ++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 36 +++- .../client/ui-workspace/tests/tree.spec.ts | 2 + .../tests/workspace-browser.spec.tsx | 91 +++++++-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 17 +- .../host/apiproxy/src/api/sessions.schema.ts | 25 ++- .../apiproxy/tests/api-proxy-search.spec.ts | 192 +++++++++++++++++- .../apiproxy/tests/client-handler.spec.ts | 14 ++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 8 + .../tests/lazy-open.compat.spec.ts | 45 ---- scripts/run-gates.ts | 49 ++++- 29 files changed, 629 insertions(+), 157 deletions(-) create mode 100644 apps/cli/tests/lazy-search-startup.compat.spec.ts delete mode 100644 packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts 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 6124b31fb4..c17f464bc5 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: 8992fdf046c1256ab61278cf5189ba56df8b4ecd -2026-07-27-web-session-search.zh.md: 764e9f3363ae321c55e401cc52b35dcba790a0b4 +2026-07-27-web-session-search.md: a709719a04a787d9bfcbba0d73263abd84fabcc1 +2026-07-27-web-session-search.zh.md: 980e2638e5a2a819433525c26e0f336c08384409 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 8992fdf046..a709719a04 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 @@ -12,9 +12,9 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with `openAt: first-search` and an in-memory database. The service is ACTIVE at boot, while its `node:sqlite` module and connection-private handle open only on the first content query. This keeps Node 22 startup output free of SQLite's experimental warning before search is used without promising to suppress the warning when search first imports the module. Each service instance owns its index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty and lazily reconciles live and persisted sessions on that first query. It remains a disposable derived index, separate from canonical JSONL persistence. -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`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, event type, and snippet type are revalidated before its snippet leaves the Host, and emitted snippets contain at most 240 Unicode code points. 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. Those retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); an oversized page, 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 stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. +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`, `assistant/message`, and `steering/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, event type, and snippet type are revalidated before its snippet leaves the Host, and emitted snippets contain at most 240 Unicode code points; the wire response schema independently enforces the same code-point 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. 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 only and preserves the query; it does not navigate to an exact event. Content matching inherits the SQLite backend's normalized literal token/phrase semantics. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching. @@ -39,4 +39,4 @@ The first content query can take longer because it imports and opens SQLite befo ## Testing -Host tests pin request validation, visible-session filtering, event/surface filters, result and snippet bounds, the shared provider-call budget, stale-generation restarts, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; a Node 22 compatibility subprocess pins warning-free mount and disposal before the first search. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy 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; 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, row rendering, and navigation semantics. A keyless assembled Web test preserves the lazy-open config while seeding an unopened persisted conversation, finds it by message content through the SQLite index, captures the sidebar result, opens it, and verifies that the query remains. 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 764e9f3363..980e2638e5 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 @@ -12,9 +12,9 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只 Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 -宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface、事件类型和 snippet 类型都会经过重新校验,其 snippet 才能离开宿主,且发出的 snippet 最多包含 240 个 Unicode 码点。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。这些重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface、事件类型和 snippet 类型都会经过重新校验,其 snippet 才能离开宿主,且发出的 snippet 最多包含 240 个 Unicode 码点;传输响应 schema 会在客户端解析时独立强制执行相同的码点上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。 @@ -39,4 +39,4 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 ## 测试 -宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享提供方调用预算、陈旧世代重启、游标与跨页去重行为、后续页取消及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;一个 Node 22 兼容性子进程将首次搜索前无警告挂载与处置固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 +宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 diff --git a/apps/cli/tests/lazy-search-startup.compat.spec.ts b/apps/cli/tests/lazy-search-startup.compat.spec.ts new file mode 100644 index 0000000000..96e9d24e1c --- /dev/null +++ b/apps/cli/tests/lazy-search-startup.compat.spec.ts @@ -0,0 +1,109 @@ +/** + * Node 22 startup-output smoke for the shipped Web CLI composition. + * + * The child runs built artifacts under plain Node with the real cordis.yml. + * Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the + * shipped quiescent disposer. + */ + +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const builtBin = join(repoRoot, 'apps/cli/lib/bin.js') +const webDist = join(repoRoot, 'apps/web/dist/index.html') +const configPath = join(repoRoot, 'apps/cli/cordis.yml') +const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1' +const builtArtifactsPresent = existsSync(builtBin) && existsSync(webDist) + +interface ConfigRow { + id?: string + config?: { openAt?: unknown } +} + +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + construct: value => String(value), +}) +const configSchema = yaml.JSON_SCHEMA.extend(jsExprType) + +/** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */ +function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolveRun, rejectRun) => { + const env: NodeJS.ProcessEnv = { + ...process.env, + DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key', + DSH_HOME: join(cwd, '.dsh'), + } + delete env.DEEPSEEK_BASE_URL + delete env.NODE_OPTIONS + delete env.NODE_NO_WARNINGS + const child = spawn(process.execPath, [ + builtBin, + 'web', + '--host', + '127.0.0.1', + '--port', + '0', + ], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + let settled = false + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) { + settled = true + child.kill('SIGTERM') + } + }) + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + }, 60_000) + child.on('error', (error) => { + clearTimeout(timer) + rejectRun(error) + }) + child.on('close', (code) => { + clearTimeout(timer) + if (!settled) { + rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + return + } + resolveRun({ stdout, stderr, code: code ?? -1 }) + }) + }) +} + +describe.skipIf(!requireBuiltArtifacts && !builtArtifactsPresent)('built CLI lazy-search startup', () => { + it('boots and disposes the shipped composition without a SQLite startup warning', async () => { + expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true) + expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true) + const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[] + const searchRow = rows.find(row => row.id === 'session-query-sqlite') + expect(searchRow?.config?.openAt).toBe('first-search') + + const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-')) + try { + const result = await runBuiltWeb(cwd) + expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u) + expect(result.code).toBe(0) + expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }, 70_000) +}) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 7744ad5c55..d5acb3ee04 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -93,18 +93,18 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) - const search = page.getByPlaceholder('搜索名称或关键词', { exact: false }) + const search = page.getByPlaceholder('Search names or content', { exact: false }) // The cold row has not been opened, so only the persisted log can satisfy // this query. First search lazily reconciles the SQLite content index. await search.fill('zzzqx-no-such-session') - await page.getByText('没有匹配结果').waitFor({ timeout: 30_000 }) + await page.getByText('No matching sessions').waitFor({ timeout: 30_000 }) await expect.poll( - () => page.getByRole('tree', { name: '搜索结果' }).getByRole('treeitem').count(), + () => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(), { timeout: 10_000 }, ).toBe(0) await search.fill('WATERFALL') - const resultTree = page.getByRole('tree', { name: '搜索结果' }) + const resultTree = page.getByRole('tree', { name: 'Search results' }) const result = resultTree.getByRole('treeitem') await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1) await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), { @@ -120,7 +120,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL') 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: '清除搜索' }).click() + 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) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index eb61619de0..c9390af80d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -163,7 +163,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise part.trim()).filter(Boolean).join('\n') } +interface FixtureSearchToken { + value: string + /** Inclusive code-point offset in the whitespace-normalized display text. */ + start: number + /** Exclusive code-point offset in the whitespace-normalized display text. */ + end: number +} + /** * Browser-safe approximation of SQLite FTS5 unicode61 token boundaries. * Keeping phrase matching token-based prevents the development fixture from * promising arbitrary within-token substring behavior that production lacks. */ -function searchTokens(value: string): string[] { - return value - .normalize('NFD') - .replace(/\p{M}+/gu, '') - .toLowerCase() - .match(/[\p{L}\p{N}\p{Co}]+/gu) ?? [] -} - -/** Count exact contiguous token-phrase occurrences in one fixture document. */ -function phraseMatchCount(document: readonly string[], phrase: readonly string[]): number { - if (phrase.length === 0 || phrase.length > document.length) return 0 - let count = 0 - for (let start = 0; start <= document.length - phrase.length; start++) { - if (phrase.every((token, offset) => document[start + offset] === token)) count++ +function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } { + const text = value.replace(/\s+/gu, ' ').trim() + const characters = Array.from(text) + const tokens: FixtureSearchToken[] = [] + let start: number | undefined + let raw = '' + const flush = (end: number): void => { + if (start !== undefined) { + const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase() + if (folded !== '') tokens.push({ value: folded, start, end }) + } + start = undefined + raw = '' } - return count + for (let index = 0; index < characters.length; index++) { + const character = characters[index] as string + const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '') + if (tokenBase === '') { + if (start !== undefined) raw += character + continue + } + if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) { + start ??= index + raw += character + } else { + flush(index) + } + } + flush(characters.length) + return { text, tokens } } -/** One-line fixture excerpt, bounded so the sidebar remains readable. */ -function searchSnippet(value: string): string { - const oneLine = value.replace(/\s+/gu, ' ').trim() - return oneLine.length <= 120 ? oneLine : `${oneLine.slice(0, 117)}…` +interface FixturePhraseMatch { + count: number + start: number + end: number +} + +/** Count exact contiguous token-phrase occurrences and retain the first display span. */ +function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch { + if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 } + let count = 0 + let firstStart = 0 + let firstEnd = 0 + for (let start = 0; start <= document.length - phrase.length; start++) { + if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue + count++ + if (count === 1) { + firstStart = document[start]?.start ?? 0 + firstEnd = document[start + phrase.length - 1]?.end ?? firstStart + } + } + return { count, start: firstStart, end: firstEnd } +} + +/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */ +function searchSnippet(value: string, matchStart: number, matchEnd: number): string { + const characters = Array.from(value) + if (characters.length <= 120) return value + const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1) + const boundedEnd = Math.min( + characters.length, + Math.max(boundedStart + 1, matchEnd), + ) + const center = Math.floor((boundedStart + boundedEnd) / 2) + let start = Math.min( + characters.length - 118, + Math.max(0, center - Math.floor(118 / 2)), + ) + let end = start + 118 + if (start === 0) { + end = 119 + } else if (end === characters.length) { + start = characters.length - 119 + } + return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}` } interface FixtureSearchCandidate { @@ -342,6 +404,8 @@ interface FixtureSearchCandidate { time: number text: string matchCount: number + matchStart: number + matchEnd: number documentLength: number } @@ -628,21 +692,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { details: {}, }) } - const query = searchTokens(request.payload.query) + const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value) const matches = sessions.flatMap((summary) => { const log = logs.get(summary.sessionId) ?? [] const current = new Set(foldSurface(log).nodes) const best = log.flatMap((event): FixtureSearchCandidate[] => { if (!current.has(event.seq)) return [] const eventText = searchEventText(event) - const matchCount = phraseMatchCount(searchTokens(eventText), query) - if (matchCount === 0) return [] + const document = searchTokenSpans(eventText) + const match = phraseMatch(document.tokens, query) + if (match.count === 0) return [] return [{ sessionId: summary.sessionId, seq: event.seq, time: event.time, - text: eventText, - matchCount, + text: document.text, + matchCount: match.count, + matchStart: match.start, + matchEnd: match.end, documentLength: Array.from(eventText).length, }] }).sort(compareSearchCandidates)[0] @@ -651,7 +718,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return ok(request, { items: matches.slice(0, 20).map(match => ({ sessionId: match.sessionId, - snippet: searchSnippet(match.text), + snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), })), hasMore: matches.length > 20, }) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 9bf0173237..71d171ee0c 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -62,6 +62,23 @@ describe('createFixtureApi', () => { if (!phrase.result.ok) throw new Error('search failed') expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息') + timing().appendUser( + 'fx-alpha', + `${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`, + ) + const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal) + if (!late.result.ok) throw new Error('late search failed') + const lateSnippet = late.result.value.items[0]?.snippet ?? '' + expect(lateSnippet).toContain('late café token') + expect(lateSnippet.startsWith('…')).toBe(true) + expect(lateSnippet.endsWith('…')).toBe(true) + expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120) + + timing().appendUser('fx-alpha', 'Greek final sigma: ος') + const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal) + if (!finalSigma.result.ok) throw new Error('final sigma search failed') + expect(finalSigma.result.value.items[0]?.snippet).toContain('ος') + const substring = await api.sessions.search(req({ query: 'ixtur' }), signal) expect(substring.result).toEqual({ ok: true, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index f89fbee5d4..f2da1e952a 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: 9cb919a1a64394d5e116d35bdddfdee738994a02 -README.zh.md: b3add7f89cb0feb7f44238b7199d0633cdfbf641 +README.md: badcfc704b456a62a921cb93f6cf637f255fca1f +README.zh.md: 53c43f880ea4ce4f0cfbf633d32f163662e9271f diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 9cb919a1a6..badcfc704b 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation modals. -The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search 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, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. +The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search 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. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index b3add7f89c..53c43f880e 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建模态框。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index d703674c01..c2b73165f3 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -27,6 +27,19 @@ import css from './WorkspaceBrowser.module.css' const EXPAND_SLIDE_MS = 300 /** Pause between the latest keystroke and a Host content-search request. */ const SEARCH_DEBOUNCE_MS = 250 +/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ +const SEARCH_QUERY_MAX_CODE_UNITS = 500 + +/** Keep controlled input and RPC payload inside the session.search wire contract. */ +function sanitizeSearchQuery(value: string): string { + const withoutNul = value.replaceAll('\0', '') + if (withoutNul.length <= SEARCH_QUERY_MAX_CODE_UNITS) return withoutNul + let end = SEARCH_QUERY_MAX_CODE_UNITS + const last = withoutNul.charCodeAt(end - 1) + const next = withoutNul.charCodeAt(end) + if (last >= 0xD800 && last <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF) end-- + return withoutNul.slice(0, end) +} const GROUP_BY_ITEMS = [ { type: 'label' as const, id: 'group-by', text: 'Group by' }, @@ -255,7 +268,7 @@ function SearchResults({ return (
-
+
{results.items.map(result => ( ))} {pending && ( -
正在搜索历史…
+
Searching session history…
)} {failed && (
- 历史内容搜索暂时不可用,仍显示名称匹配。 + Content search is temporarily unavailable. Showing name matches.
)} {!pending && results.items.length === 0 && ( -
没有匹配结果
+
No matching sessions
)} {results.hasMore && ( -
仅显示前 20 项,请缩小搜索范围。
+
Showing the first 20 results. Narrow your search.
)}
@@ -308,7 +321,7 @@ export function WorkspaceBrowser({ // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') - const normalizedQuery = query.trim() + const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ query: '', status: 'idle', @@ -439,11 +452,11 @@ export function WorkspaceBrowser({ {/* Expanded: the row is a click-to-focus field (the leading icon is decorative). Rail: the icon is the region's search control. */}
{ if (wide) searchInput.current?.focus() }}> - + diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 87ec0dfde7..954335fde2 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -179,9 +179,11 @@ describe('conversation slot inject surface', () => { // hooks compartment still present so the render side's hook order holds. const absent = injectFn(undefined) expect(absent.keyboard).toBeUndefined() + expect(absent.toggleCommandMenu).toBeUndefined() expect(absent.stop).toBeUndefined() expect(absent.hooks.notices.getSnapshot()).toBeNull() expect(absent.hooks.lexicon.getSnapshot().size).toBe(0) + expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull() // A scope whose service tree lost 'conversation' (the feature fiber // unloaded while a retained inject closure re-runs): fails loud too. const stop = injectFn(ROOT).stop! diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c0bfda7f4a..09abd47458 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -47,6 +47,8 @@ interface BenchOptions { overlay?: React.ReactNode leftItems?: React.ReactNode rightItems?: React.ReactNode + commandMenuOpen?: boolean + toggleCommandMenu?: (selection: { start: number; end: number }) => void } /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ @@ -74,6 +76,7 @@ function bench(over?: BenchOptions) { promptError: over?.promptError ?? null, })) const stop = vi.fn() + const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, owner }) @@ -97,8 +100,10 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(menuLauncher), stop, command: () => Promise.resolve(true), // Mirrors the en 'command.hint' locale entries the production apply wires in. @@ -120,7 +125,7 @@ function bench(over?: BenchOptions) { const button = view.container.querySelector( `button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`, )! - return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls } + return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher } } describe('Enter semantics', () => { @@ -205,7 +210,7 @@ describe('running and lock semantics (queue cut 1)', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.disabled).toBe(true) expect(textarea.placeholder).toBe('Session unavailable') - expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Commands') as HTMLButtonElement).disabled).toBe(true) }) it('idle primary sends and disables on empty draft', () => { @@ -438,10 +443,10 @@ describe('strips and variants', () => { }) }) -describe('placeholder chrome and control seats', () => { - it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { +describe('command launcher chrome and control seats', () => { + it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { const { view, slotCalls } = bench() - expect(view.getByLabelText('Add attachment')).toBeTruthy() + expect(view.getByLabelText('Commands')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^Access mode/)).toBeNull() // Both seats dispatched, nothing rendered. @@ -450,6 +455,18 @@ describe('placeholder chrome and control seats', () => { expect(view.queryByLabelText('Model')).toBeNull() }) + it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => { + const toggleCommandMenu = vi.fn() + const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu }) + textarea.setSelectionRange(2, 7) + const launcher = view.getByLabelText('Commands') + expect(launcher.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(launcher) + expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 }) + act(() => { menuLauncher.set('command') }) + expect(launcher.getAttribute('aria-expanded')).toBe('true') + }) + it('the Access chip renders the projection value and submits /permission on pick', async () => { const permissions = { options: [ @@ -489,10 +506,10 @@ describe('placeholder chrome and control seats', () => { expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true) }) - it('disabled locks the Access chip and attach control (running does not)', () => { + it('disabled locks the Access chip and command launcher (running does not)', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) - expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Commands') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index a9c00b0748..4e8fc9efee 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -43,8 +43,10 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(createSnapshotStore(null)), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), @@ -170,7 +172,7 @@ describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) expect((textarea).disabled).toBe(true) - expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Commands') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 1c7bbe50ec..b45397294c 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -129,8 +129,18 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: (selection) => { + const snapshot = shell.snapshot + controller.toggleSource('command', { + trigger: '/', + query: '', + position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', + span: { ...selection, draftRev: snapshot.draftRev }, + }) + }, useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(controller.launcher), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 3ed459b5a9..d3feba86a6 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -145,8 +145,10 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} + useMenuLauncher={bindSnapshotSelector(createSnapshotStore(null))} stop={stop} command={() => Promise.resolve(true)} translateHint={(key: string) => key} diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index 494ccdad20..5f1f5f23c4 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/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-slash/README.md -README.md: 29f1a71ce20f898ffe2ab3a1c6f3d73a7aecfe38 -README.zh.md: 03dac56870de5b083124716825001009b4293736 +README.md: 5d277a83c5f0bc4bcec5871e0618af28afb7b6d2 +README.zh.md: 195aec6b76517fcf5cfc0933eb39b180f03a8628 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index 29f1a71ce2..5d277a83c5 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. -MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. +MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 03dac56870..195aec6b76 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 -MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 +MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 `/client` 导出表层是插件主体(`apply`/`inject`)、`SlashService`、`MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。 diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index 3a8e85afc3..0b57f3dd74 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -40,6 +40,12 @@ export interface SlashControllerDeps { export class SlashController { /** Menu state store (per-session; survives session switches, dies with the scope). */ readonly menu: SnapshotStore = createSnapshotStore(MENU_CLOSED) + /** + * Name of the source opened through the programmatic launcher, or null for + * trigger-detected/closed menus. Composer chrome subscribes to this store + * for the launcher's expanded state without owning a second menu model. + */ + readonly launcher: SnapshotStore = createSnapshotStore(null) /** * Aggregated hot reference lexicon, grouped by trigger (decision 21): * sources implementing the lexicon hook are polled with the session @@ -81,6 +87,8 @@ export class SlashController { */ track(draft: string, caret: number, guard: TriggerGuard, draftRev: number): void { if (this.disposed) return + const launched = this.launcher.getSnapshot() !== null + this.clearLauncher() const raw = detectTrigger(draft, caret, guard) if (raw === null) { this.hit = null @@ -90,7 +98,7 @@ export class SlashController { } const hit: TriggerHit = { ...raw, span: { ...raw.span, draftRev } } const prev = this.menu.getSnapshot() - const same = prev.open && prev.hit !== null + const same = !launched && prev.open && prev.hit !== null && prev.hit.trigger === hit.trigger && prev.hit.query === hit.query && prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end this.hit = hit @@ -101,13 +109,40 @@ export class SlashController { this.reduce({ type: 'close' }) return } - if (!prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) { + if (launched || !prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) { this.menu.set(seedGroups(this.menu.getSnapshot(), roster.map(s => s.name))) } this.reduce({ type: 'hit', hit }) this.fetchCandidates(hit, roster) } + /** + * Toggle a menu containing exactly one registered source. The supplied hit + * is a synthetic selection span rather than a typed trigger token, but + * picks deliberately reuse the ordinary source callback and scoped input + * mutation pipeline. + * @param source - registered source name under `hit.trigger`. + * @param hit - synthetic hit carrying position and pick-time draft CAS. + */ + toggleSource(source: string, hit: TriggerHit): void { + if (this.disposed) return + if (this.launcher.getSnapshot() === source && this.menu.getSnapshot().open) { + this.dismiss() + return + } + const match = this.deps.roster.sources(hit.trigger).find(item => item.name === source) + if (match === undefined) { + this.dismiss() + return + } + this.stopFetch() + this.hit = hit + this.launcher.set(source) + this.menu.set(seedGroups(this.menu.getSnapshot(), [source])) + this.reduce({ type: 'hit', hit }) + this.fetchCandidates(hit, [match]) + } + /** * Pointer pick from MenuView: route the clicked candidate through onPick * and execute claim/insert outcomes via the scoped input events. @@ -349,9 +384,14 @@ export class SlashController { this.fetch = null } + private clearLauncher(): void { + if (this.launcher.getSnapshot() !== null) this.launcher.set(null) + } + private reduce(ev: MenuEvent): void { const cur = this.menu.getSnapshot() const next = menuReduce(cur, ev) if (next !== cur) this.menu.set(next) + if (!next.open) this.clearLauncher() } } diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index 099e2bb4f5..6f398442b9 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -351,6 +351,57 @@ describe('track', () => { }) }) +describe('programmatic source launcher', () => { + it('opens only the requested source and reuses its ordinary pick span', async () => { + const command = readySource('/', 'command', [{ name: 'goal' }]) + const skill = readySource('/', 'skill', [{ name: 'review' }]) + const { controller } = controllerBench([command.source, skill.source]) + const hit = { + trigger: '/' as const, + query: '', + position: 'leading' as const, + span: { start: 2, end: 5, draftRev: 7 }, + } + + controller.toggleSource('command', hit) + await tick() + + expect(controller.launcher.getSnapshot()).toBe('command') + expect(controller.menu.getSnapshot()).toMatchObject({ + open: true, + hit, + groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }], + }) + controller.pick('command', 0) + expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span }) + expect(skill.picks).toHaveLength(0) + expect(controller.launcher.getSnapshot()).toBeNull() + }) + + it('toggles closed, and typed tracking returns to the full trigger roster', async () => { + const command = readySource('/', 'command', [{ name: 'goal' }]) + const skill = readySource('/', 'skill', [{ name: 'review' }]) + const { controller } = controllerBench([command.source, skill.source]) + const hit = { + trigger: '/' as const, + query: '', + position: 'leading' as const, + span: { start: 0, end: 0, draftRev: 1 }, + } + + controller.toggleSource('command', hit) + controller.toggleSource('command', hit) + expect(controller.menu.getSnapshot().open).toBe(false) + expect(controller.launcher.getSnapshot()).toBeNull() + + controller.toggleSource('command', hit) + controller.track('/g', 2, { tier: 'plain' }, 2) + await tick() + expect(controller.launcher.getSnapshot()).toBeNull() + expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill']) + }) +}) + describe('scope-birth warm', () => { it('construction warms every source once with the session projection', () => { const cmd = deferredSource('/', 'command') From f04b35c6a43f058b052f1d074e4da18ca4424ec2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 21:49:33 +0800 Subject: [PATCH 19/33] fix(test): isolate jsdom storage on Node 26 --- .../2026-07-06-node-engine-floor.i18n.yaml | 6 ++--- .../process/2026-07-06-node-engine-floor.md | 4 +-- .../2026-07-06-node-engine-floor.zh.md | 4 +-- ...itest-jsdom-webstorage-ownership.i18n.yaml | 6 +++++ ...07-30-vitest-jsdom-webstorage-ownership.md | 26 +++++++++++++++++++ ...30-vitest-jsdom-webstorage-ownership.zh.md | 26 +++++++++++++++++++ .../client/ui-trajectory/tests/views.spec.tsx | 4 +-- scripts/run-gates.spec.ts | 17 ++++++++++++ scripts/run-gates.ts | 5 ++++ scripts/vitest-environment.compat.spec.ts | 14 ++++++++++ vitest.config.ts | 3 +++ vitest.e2e.config.ts | 2 ++ vitest.shared.ts | 5 ++++ vitest.snapshot.config.ts | 2 ++ vitest.web.config.ts | 2 ++ 15 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md create mode 100644 .agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md create mode 100644 scripts/vitest-environment.compat.spec.ts create mode 100644 vitest.shared.ts diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index 2cb7f1009d..50d3e498a5 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-06-node-engine-floor.md: f1754ea7ca32452a04c6cd8a0599568f602e47dd -2026-07-06-node-engine-floor.zh.md: 9d376a639378d3a0b9b645aa36c1a5d320d1d147 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-node-engine-floor.md +2026-07-06-node-engine-floor.md: ef047d885a442106a35922f4716d2996d8a98ca7 +2026-07-06-node-engine-floor.zh.md: a0281addf7d4327d7f6ea30e3a3f0f40d6782bd0 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md index f1754ea7ca..ef047d885a 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md @@ -10,7 +10,7 @@ The Node 22 branch of the root `engines.node` range is a contract for the instal ## Decision -Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibility matrix on `['22.19', 24, 26]`. Every matrix leg runs the TypeScript typecheck plus a keyless source-mode worker smoke, so the floor is exercised through both a complete source typecheck and a real unbuilt runtime path. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor. +Set `engines.node` to `^22.19.0 || >=24.0.0` and test keyless CI on `['22.19', 24, 26]`. The primary Node 24 jobs own the complete typecheck and unit coverage inventory; every version runs focused source-worker, Zstandard, source-launch, and [jsdom storage](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) smokes without repeating that inventory. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor. Two Node features gate the source runtime: @@ -24,7 +24,7 @@ Those source features clear on the 22.x line at **22.18**, but the installed Pi ## Consequences - The advertised LTS branch no longer undercuts the Pi adapter dependency floor. -- CI proves the Node 22 LTS floor directly with Node 22.19, keeps the Node 24 branch on `node: 24`, and keeps Node 26 for the next even line; each leg typechecks the source graph and launches the unbuilt workflow worker for real. +- CI proves the Node 22 LTS floor directly with Node 22.19, keeps primary coverage on `node: 24`, and exercises Node 26 as the next even line; focused compatibility smokes run on all three versions. - The built-bin smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents. - A future dependency or source API that raises the runtime floor must move `engines.node`, the compatibility matrix, and this Agent Note in the same change. diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md index 9d376a6393..a0281addf7 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 keyless CI 兼容性矩阵中测试 `['22.19', 24, 26]`。每条矩阵分支都运行 TypeScript 类型检查加一次 keyless 的源码模式 worker 冒烟测试,因此引擎下限通过完整的源码类型检查和真实的未构建运行时路径两条路径得到验证。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。 +将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 `['22.19', 24, 26]` 上运行 keyless CI。主要的 Node 24 任务负责整套类型检查和单元测试覆盖率任务;三个版本均运行 source-worker、Zstandard、source-launch 和 [jsdom 存储](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) 专项冒烟测试,不重复这套类型检查和覆盖率任务。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。 两个 Node 特性决定了源码运行时的门槛: @@ -24,7 +24,7 @@ Status: implemented ## 后果 - 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。 -- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,Node 24 分支保持 `node: 24`,Node 26 用于下一个偶数线;每条分支都对源码图执行类型检查,并实际启动未构建的工作流 worker。 +- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,将主要覆盖率任务保留在 `node: 24`,并用 Node 26 验证下一个偶数线;三个版本均运行聚焦的兼容性冒烟测试。 - built-bin 冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此测试保持其文档所述的纯 `node lib/bin.js` 路径。 - 未来若依赖或源码 API 提高运行时下限,必须在同一变更中同步调整 `engines.node`、兼容性矩阵和本 Agent Note(agent 决策记录)。 diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml new file mode 100644 index 0000000000..e829874e9d --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.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/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md +2026-07-30-vitest-jsdom-webstorage-ownership.md: 3956a7566fa1c79a767636bce9a19f16588126e2 +2026-07-30-vitest-jsdom-webstorage-ownership.zh.md: 9080ee2762b74bf2efdaccd7a5905672001bc0e8 diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md new file mode 100644 index 0000000000..3956a7566f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md @@ -0,0 +1,26 @@ +# Agent Note: Keep browser storage owned by jsdom in Vitest + +Status: implemented + +English | [中文](2026-07-30-vitest-jsdom-webstorage-ownership.zh.md) + +## Problem + +The supported Node range includes releases that reserve a process-wide `globalThis.localStorage`. Node 26 exposes that property as `undefined` without `--localstorage-file`; Vitest sees the reserved key and does not project jsdom's isolated `Storage` object over it. Component suites then fail before exercising product behavior, while the primary Node 24 coverage lane remains green because that runtime does not reserve the key by default. + +## Decision + +Vitest workers disable Node's process-wide Web Storage when the runtime advertises the `--webstorage` flag. The configuration passes `--no-webstorage` through each test project's `execArgv`; runtimes without that flag receive no argument. Node-environment suites therefore stay browser-free, and files selecting jsdom through `@vitest-environment jsdom` receive jsdom's isolated `localStorage`. + +The Node compatibility aggregate runs a dedicated jsdom smoke on every advertised compatibility line. It asserts both the conditional worker argument and usable storage, so a future Node or Vitest change cannot leave the primary Node 24 suite as the only signal. + +## Alternatives considered + +- **Set `NODE_OPTIONS=--no-webstorage` in package scripts or CI.** Rejected because it leaks test-runner policy into subprocesses and misses direct `pnpm exec vitest` invocations. +- **Pass `--localstorage-file` to Node.** Rejected because one process-wide persistent store has different ownership and isolation semantics from browser storage created per jsdom environment. +- **Patch `globalThis.localStorage` in setup code or guard every component test.** Rejected because setup would depend on Vitest's private jsdom projection details, while per-test guards hide a broken browser environment and duplicate policy across suites. +- **Pin tests to Node 24.** Rejected because the package engine advertises newer even Node lines and the compatibility matrix exists to expose their runtime changes. + +## Consequences + +The same `pnpm test` command works on Node releases with and without built-in Web Storage. Test workers deliberately cannot exercise Node's process-wide Web Storage; a future product need for that API requires a separate explicit test configuration rather than weakening jsdom isolation. The compatibility lane adds one focused Vitest process instead of duplicating the complete unit inventory on every Node version. diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md new file mode 100644 index 0000000000..9080ee2762 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md @@ -0,0 +1,26 @@ +# Agent Note: 在 Vitest 中将浏览器存储交由 jsdom 管理 + +Status: implemented + +[English](2026-07-30-vitest-jsdom-webstorage-ownership.md) | 中文 + +## 问题 + +受支持的 Node 版本范围包含会预留进程级 `globalThis.localStorage` 的版本。未设置 `--localstorage-file` 时,Node 26 将该属性暴露为 `undefined`;Vitest 检测到这个预留键后,不会用 jsdom 的隔离 `Storage` 对象覆盖该属性。因此,组件测试套件尚未验证产品行为便会失败,而主要的 Node 24 覆盖率分支仍能通过,因为该运行时默认不会预留此键。 + +## 决策 + +当运行时声明支持 `--webstorage` 标志时,Vitest worker 会禁用 Node 的进程级 Web Storage。配置通过每个测试项目的 `execArgv` 传入 `--no-webstorage`;未声明该标志的运行时则不传入此参数。因此,Node 环境测试套件不加载浏览器环境,而通过 `@vitest-environment jsdom` 选择 jsdom 的文件会获得 jsdom 隔离的 `localStorage`。 + +Node 兼容性汇总任务会在每条声明支持的兼容版本线上运行专用的 jsdom 冒烟测试。该测试同时断言 worker 参数按条件传入且存储可用,因此未来 Node 或 Vitest 的变化不会让主要的 Node 24 测试套件成为唯一检测信号。 + +## 曾考虑的替代方案 + +- **在包脚本或 CI 中设置 `NODE_OPTIONS=--no-webstorage`。** 否决:这会将测试运行器策略传播到子进程,也无法覆盖直接调用 `pnpm exec vitest` 的情况。 +- **向 Node 传入 `--localstorage-file`。** 否决:单个进程级持久化存储与每个 jsdom 环境分别创建的浏览器存储具有不同的归属和隔离语义。 +- **在初始化代码中修改 `globalThis.localStorage`,或为每个组件测试增加保护逻辑。** 否决:初始化逻辑会依赖 Vitest 私有的 jsdom 映射细节,而逐测试添加的保护逻辑会掩盖浏览器环境损坏,并在多个测试套件中重复该策略。 +- **将测试固定在 Node 24。** 否决:包的引擎范围声明支持更新的偶数 Node 版本线,而兼容性矩阵正是为了暴露这些版本的运行时变化。 + +## 后果 + +同一条 `pnpm test` 命令在有无内置 Web Storage 的 Node 版本上均可运行。测试 worker 被有意禁止使用 Node 的进程级 Web Storage;未来若产品需要该 API,必须使用独立且显式的测试配置,而不能削弱 jsdom 隔离。兼容性分支只增加一个专项 Vitest 进程,无需在每个 Node 版本上重复整套单元测试。 diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index c79b7407ab..df476101a9 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -35,9 +35,7 @@ afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. beforeEach(() => { - // Node 22+ exposes an experimental localStorage global that is undefined - // without --localstorage-file; only clear when a real Storage is present. - if (typeof localStorage !== 'undefined') localStorage.clear() + localStorage.clear() }) /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 9a67e64929..624d46dfd1 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -135,6 +135,23 @@ describe('Oxlint gate', () => { }) }) +describe('Node compatibility graph', () => { + it('runs the jsdom environment smoke on every advertised Node line', () => { + const subject = withPnpmEntrypoint(() => gatesForMode('node-compat')) + + expect(subject.find(item => item.id === 'vitest-jsdom-smoke')).toMatchObject({ + label: 'Vitest jsdom smoke', + args: [ + '/private/pnpm.cjs', + 'exec', + 'vitest', + 'run', + 'scripts/vitest-environment.compat.spec.ts', + ], + }) + }) +}) + describe('Node 24 consumer graph', () => { it('owns the eight-command pool and orders restored-artifact consumers', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 167d226ff8..b84aa903e1 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -292,6 +292,11 @@ function nodeCompatSmokeGates(): Gate[] { 'run', 'apps/cli/tests/source-launch.compat.spec.ts', ], { label: 'dsh source-launch smoke' }), + pnpmExec('vitest-jsdom-smoke', [ + 'vitest', + 'run', + 'scripts/vitest-environment.compat.spec.ts', + ], { label: 'Vitest jsdom smoke' }), ] } diff --git a/scripts/vitest-environment.compat.spec.ts b/scripts/vitest-environment.compat.spec.ts new file mode 100644 index 0000000000..fae1fc29fd --- /dev/null +++ b/scripts/vitest-environment.compat.spec.ts @@ -0,0 +1,14 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' + +describe('Vitest jsdom compatibility', () => { + it('provides isolated browser storage instead of Node process storage', () => { + if (process.allowedNodeEnvironmentFlags.has('--webstorage')) { + expect(process.execArgv.filter(argument => argument === '--no-webstorage')).toHaveLength(1) + } + localStorage.setItem('dsh-vitest-storage-probe', 'available') + + expect(localStorage.getItem('dsh-vitest-storage-probe')).toBe('available') + localStorage.removeItem('dsh-vitest-storage-probe') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 750bb6ddf2..460d852756 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +import { vitestExecArgv } from './vitest.shared.ts' // Resolution facade shared by every plugin instance below: tsconfig.base.json // has no include, which vite-tsconfig-paths treats as match-all, so its paths @@ -62,6 +63,7 @@ export default defineConfig({ plugins: [pathsPlugin()], test: { name: 'thread-safe', + execArgv: vitestExecArgv, // Node 24 has aborted in its CJS lexer from a macOS arm64 worker // thread. A fork contains that external runtime failure to the test // process; other hosts retain the lower-overhead thread pool. @@ -78,6 +80,7 @@ export default defineConfig({ plugins: [pathsPlugin()], test: { name: 'process-bound', + execArgv: vitestExecArgv, pool: 'forks', setupFiles: ['./scripts/test-invariants.ts'], include: processBoundTests, diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 3f9ceada28..d8e6aa53a7 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,5 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +import { vitestExecArgv } from './vitest.shared.ts' // Real-API suite, separate because it spends tokens. Each test self-skips without // its provider credential for keyless CI; credentialed workflows preflight the @@ -37,6 +38,7 @@ export default defineConfig({ // entirely. plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { + execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built // frontend dist and runs under vitest.web.config.ts (the test:web job). diff --git a/vitest.shared.ts b/vitest.shared.ts new file mode 100644 index 0000000000..506fabb380 --- /dev/null +++ b/vitest.shared.ts @@ -0,0 +1,5 @@ +/** + * Worker arguments that keep process-wide Web Storage from shadowing jsdom storage. + * Node lists the positive spelling in `allowedNodeEnvironmentFlags` for this negatable flag. + */ +export const vitestExecArgv = process.allowedNodeEnvironmentFlags.has('--webstorage') ? ['--no-webstorage'] : [] diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index b14422d2f8..6a08eeb3eb 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,6 +1,7 @@ import { availableParallelism } from 'node:os' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +import { vitestExecArgv } from './vitest.shared.ts' const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5 @@ -41,6 +42,7 @@ export default defineConfig({ // this (the root tsconfig is a solution file with no paths). plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { + execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], include: [ 'scripts/**/*.snapshot.ts', diff --git a/vitest.web.config.ts b/vitest.web.config.ts index fd9ca502bc..e6e1efa1f4 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -1,5 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +import { vitestExecArgv } from './vitest.shared.ts' // Web browser lane: real host entry points, built-client interaction snapshots, // and replayed keyless e2e scenarios outside the unit/e2e includes. Linux PR CI @@ -18,6 +19,7 @@ export default defineConfig({ // workspace imports to source like every other lane. plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { + execArgv: vitestExecArgv, include: [ 'apps/web/tests/**/*.e2e.ts', 'apps/web/tests/**/*.snapshot.ts', From 1986de9ac14d8eb5ae5b0d8c79341d9faa3e2369 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 22:42:25 +0800 Subject: [PATCH 20/33] fix(web): show plan status only while active --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 3 +- ...web-input-machine-and-slash-pipeline.zh.md | 3 +- apps/web/tests/lifecycle-chrome.e2e.ts | 57 +++++++++- .../snapshots/code-mode-round/ui.expected.md | 1 - .../cordis-tool-round/ui.expected.md | 1 - .../snapshots/fresh-round-trip/ui.expected.md | 1 - .../lifecycle-chrome/hero.expected.md | 1 - .../lifecycle-chrome/plan-active.expected.md | 39 +++++++ .../lifecycle-chrome/reloaded.expected.md | 1 - .../live-interactions/cancel.expected.md | 1 - .../live-interactions/error-auth.expected.md | 1 - .../live-interactions/retry.expected.md | 1 - .../snapshots/message-actions/ui.expected.md | 1 - .../question-composer/answered.expected.md | 1 - .../queue-actions/editing.expected.md | 1 - .../snapshots/queue-actions/ui.expected.md | 1 - .../snapshots/seeded-history/ui.expected.md | 1 - .../snapshots/steering/settled.expected.md | 1 - packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 4 +- packages/client/ui-plan/README.zh.md | 4 +- packages/client/ui-plan/package.json | 2 + .../src/client/PlanModeControl.module.css | 34 +++--- .../ui-plan/src/client/PlanModeControl.tsx | 53 +++++----- packages/client/ui-plan/src/client/index.ts | 24 ++--- .../ui-plan/tests/browser-plugin.spec.ts | 18 ++-- .../ui-plan/tests/plan-mode-control.spec.tsx | 100 +++++++----------- packages/client/ui-plan/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 30 files changed, 212 insertions(+), 157 deletions(-) create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index ea630a99f9..09ab4376b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 2793e9045fe5a3c82f52c65503dd4a8cdf6a0596 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: e3a35c4e55525fedd835eace973f114bd15da37b +2026-07-25-web-input-machine-and-slash-pipeline.md: c3deadb34d3a633525dde701c92bcc98c05e5d6e +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7a6988423dcdffebb0a28735146439c8ade0a862 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 2793e9045f..c3deadb34d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -101,7 +101,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. - `conversation.composer.dock` — the stats band on the composer's top edge. - `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions. -- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. +- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. The plan seat stays empty while inactive because the shared Command source owns entry; an effective plan target renders the warn-state `Plan ×` status button, whose only action is `/plan off`. - `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current. ### Testing discipline @@ -122,6 +122,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth | +| An always-visible Plan on/off toggle | The shared Command source already owns entry; a second entry point turns a status seat into redundant mode chrome | | A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability | | All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity | diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index e3a35c4e55..7a6988423d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -101,7 +101,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 - `conversation.composer.dock`——composer 上沿统计带。 - `conversation.input.left` / `conversation.input.right`——工具行左右区。 -- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。 +- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。plan seat 未激活时保持为空,因为入口归共享 Command source 所有;有效 plan 目标会渲染 warn 状态的 `Plan ×` 状态按钮,其唯一动作是 `/plan off`。 - `conversation.hero.workspace`(root scope)——无 session / blank Hero 共用的 Workspace picker;pick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。 ### 测试纪律 @@ -122,6 +122,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | | 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 | +| 始终可见的 Plan 开/关切换 | 入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome | | 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 | | 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 | diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 746cd37e36..97ffefccf7 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') +const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') @@ -85,6 +86,60 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await expect.poll(() => menu.count()).toBe(0) }) + it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { + const activeScaffold = await launchWebScaffold() + const activePage = await newEnglishPage(browser) + const activeTripwire = watchConsole(activePage) + try { + await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' }) + await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(activePage) + const input = activePage.locator('textarea').first() + await activePage.getByRole('button', { name: 'Commands' }).click() + const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' }) + await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click() + await expect.poll(() => input.inputValue()).toBe('/plan ') + await input.press('Enter') + const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' }) + await planButton.waitFor({ timeout: 10_000 }) + const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd) + await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE) + const planStyle = await planButton.evaluate((element) => { + const probe = document.createElement('span') + probe.style.color = 'var(--dsw-alias-state-warn-label)' + probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)' + document.body.append(probe) + const actual = getComputedStyle(element) + const reference = getComputedStyle(probe) + const result = { + color: actual.color, + backgroundColor: actual.backgroundColor, + borderRadius: actual.borderRadius, + fontSize: actual.fontSize, + referenceColor: reference.color, + referenceBackgroundColor: reference.backgroundColor, + } + probe.remove() + return result + }) + expect(planStyle.color).toBe(planStyle.referenceColor) + expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor) + expect(planStyle.borderRadius).toBe('999px') + expect(planStyle.fontSize).toBe('13px') + await planButton.click() + await expect.poll(() => planButton.count()).toBe(0) + expect(activeTripwire.pageErrors).toEqual([]) + expect(activeTripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined) + throw error + } finally { + await activePage.close() + await activeScaffold.close() + } + }) + it('sends the first prompt from the empty-state hero (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send')) if (MODE !== 'record') { @@ -182,7 +237,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'reloaded.expected.md', + 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 31476daefd..7b957e5899 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -36,7 +36,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 4425921fdf..5abf911c83 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -50,7 +50,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 64c62f85d6..ec99a493f3 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -33,7 +33,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 783964ed31..bed9df014c 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -29,7 +29,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md new file mode 100644 index 0000000000..8bb1351e0f --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -0,0 +1,39 @@ +- button "New session" +- button "Collapse sidebar": + - img +- button "New session": + - img + - text: New Session +- text: Workspaces +- button "Group by": + - img +- button "Create workspace": + - img +- button "Search sessions": + - img +- textbox "Search name, keywords..." +- tree "Sessions": + - treeitem "workspace 1 session" [expanded]: + - img + - text: workspace 1 session + - treeitem "New Session now" [selected] +- button "Settings": + - img + - text: Settings +- text: Let's start building +- button "Choose workspace": + - img + - text: workspace + - img +- textbox "Describe what you want to build" +- button "Commands": + - img +- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- button "Plan mode on, press to turn off": Plan +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 详情 +- button "关闭详情" +- text: 点击消息流中的工具行查看详情 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 81f1ab608b..b323b67a30 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -25,7 +25,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index f65b090a16..2b14062211 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -22,7 +22,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 0d013f819d..364cf54ddb 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -15,7 +15,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 11bb665e71..c008fc4dba 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -25,7 +25,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index b15a665c45..369f03c6f1 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -36,7 +36,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index db0c2cfd3a..ec9a074e72 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -33,7 +33,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 7e67544f04..0f890e271f 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -29,7 +29,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 48c288909c..81144381cf 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -23,7 +23,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index c520dafae9..8b6a393664 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -39,7 +39,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 1172d3ca5d..193f19ef05 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -33,7 +33,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 199210a863..c1abb744cc 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/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-plan/README.md -README.md: 1d22c057b439ff337bf9daadcdba96dd4cca4540 -README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391 +README.md: 2f83dd738e11ada5b24a55d1eec97e154ffd2aea +README.zh.md: a026390191179f6492a55ee931a3406d8e1953e8 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 1d22c057b4..2f83dd738e 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster. -Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win). +Plan mode is entered through the `/plan` command path: users can choose Plan from the composer's `+` Command menu or type `/plan`, while this package renders no inactive plan control. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders the warn-colored "Plan ×" status button, which executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win). The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit. @@ -22,4 +22,4 @@ Entering or leaving plan mode changes the active `plan:policy` system-prompt sec - **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies. - **The chip belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its chip. -- **No UI entry point** — plan mode is entered by typing `/plan`; a session with the capability but inactive mode shows no affordance in the tool row. +- **No inactive plan control** — entry uses the shared Command source; a session with the capability but inactive mode shows no plan affordance in the tool row. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 183b8ef777..a026390191 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -4,7 +4,7 @@ Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。 -plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 +plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan,也可以输入 `/plan`,而本包(package)不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。 @@ -22,4 +22,4 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m - **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。 - **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。 -- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。 +- **无未激活态 plan 控件**——入口使用共享 Command source;有能力但 mode 未激活的会话在工具行不显示 plan 入口。 diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index a2aec47985..d7524cbc7d 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-plan-mode": "^0.0.1", @@ -48,6 +49,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index f79e9073db..46e893aee8 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -1,5 +1,4 @@ -/* Plan-mode toggle chip: quiet while off; the pressed state takes the - business accent pair (same token pairing as the trajectory user badge). */ +/* Active plan status follows Figma's warn-state pill. */ .wrap { display: inline-flex; @@ -10,30 +9,25 @@ .chip { display: inline-flex; align-items: center; - padding: 4px 8px; + gap: 4px; + min-width: 34px; + padding: 2px 8px; border: none; - border-radius: 8px; - background: transparent; - color: var(--dsw-alias-label-secondary); - font-size: 14px; + border-radius: 999px; + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-label); + font-size: 13px; + font-weight: 500; line-height: 20px; cursor: pointer; } .chip:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Hovering keeps the pressed accent: the higher-specificity hover rule above - would otherwise swap it back to the neutral hover wash. */ -.chip[aria-pressed='true'], -.chip[aria-pressed='true']:hover:not(:disabled) { - color: var(--dsw-alias-state-business-primary); - background: var(--dsw-alias-state-business-tertiary); + color: var(--dsw-alias-state-warn-primary); } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-label-secondary); + outline: 2px solid var(--dsw-alias-state-warn-label); outline-offset: 2px; } @@ -42,6 +36,12 @@ cursor: default; } +.close { + display: inline-flex; + align-items: center; + color: currentColor; +} + .error { color: var(--dsw-alias-state-error-primary); font-size: 12px; diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index 961d7435ef..a6fc74f77b 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and // its {locked} owner share). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -11,16 +12,14 @@ export type PlanChipProps = PropsRuntime<'conversation.input.plan'> & InjectFace /** - * Plan-mode toggle over the host-computed `plan` projection. The chip renders - * whenever the capability is present and reflects the effective target as its - * pressed state (`pending ? !active : active` — a folded host value, not - * client optimism, so an arriving frame corrects it). Clicking executes - * /plan or /plan off toward the opposite target. + * Plan-mode status over the host-computed `plan` projection. The chip renders + * only while the effective target is plan mode (`pending ? !active : active` + * — a folded host value, not client optimism) and executes /plan off. */ -export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) { +export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) { const plan = useProjection('plan') - const [busy, setBusy] = useState(false) - const [error, setError] = useState<{ text: string; detail: string } | null>(null) + const [leaving, setLeaving] = useState(false) + const [error, setError] = useState(null) const aliveRef = useRef(true) useEffect(() => { @@ -30,25 +29,21 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) } }, []) - // Absent capability (no plan-mode host plugin / no session yet): no seat - // content — without the capability there is nothing to toggle. if (plan === undefined) return null const target = plan.pending ? !plan.active : plan.active + if (!target) return null - const toggle = (): void => { - // No busy/locked guard: both disable the button, so no click arrives. - const on = !target - const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败' - setBusy(true) + const off = (): void => { + setLeaving(true) setError(null) - void setPlanMode(on).then((failure) => { + void exitPlanMode().then((failure) => { if (!aliveRef.current) return - setBusy(false) - setError(failure === null ? null : { text: failText, detail: failure }) + setLeaving(false) + setError(failure) }, (reason: unknown) => { if (!aliveRef.current) return - setBusy(false) - setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) }) + setLeaving(false) + setError(reason instanceof Error ? reason.message : String(reason)) }) } @@ -57,17 +52,17 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) - {error !== null && {error.text}} + {error !== null && 退出 plan mode 失败} ) } diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 77779efb02..d85e508922 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -1,11 +1,11 @@ /** * Plan control plugin, browser half: occupies the composer's named - * `conversation.input.plan` seat with a plan-mode toggle chip. While the - * `plan` projection is present the chip renders in both states and executes - * /plan or /plan off through `command.execute` toward the opposite target; - * an absent projection (no capability) leaves the seat empty. Reads ride the - * generic projection pair through the standard-kit `useProjection` (an absent - * key is capability absence); zero client-side plan state. + * `conversation.input.plan` seat with an active-state status chip. Plan mode + * is entered through the command source; while the projection's effective + * target is plan mode the chip renders and executes /plan off through + * `command.execute`, otherwise the seat stays empty. Reads ride the generic + * projection pair through the standard-kit `useProjection`; zero client-side + * plan state. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -18,11 +18,10 @@ import { PlanChip } from './PlanModeControl.tsx' /** Injected business face of the composer plan seat. */ export interface PlanChipInjected { /** - * Switch plan mode by executing /plan (on) or /plan off. - * @param on - desired target: true enters plan mode, false leaves it. + * Leave plan mode by executing /plan off. * @returns null on admitted execution; a user-visible failure line otherwise. */ - setPlanMode: (on: boolean) => Promise + exitPlanMode: () => Promise } /** @@ -39,12 +38,11 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.slots.register({ name: 'conversation.input.plan', inject: (sessionId: SessionId): PlanChipInjected => ({ - setPlanMode: async (on) => { - const line = on ? '/plan' : '/plan off' + exitPlanMode: async () => { const connection = ctx.get('connection') as ConnectionHandle - const { result } = await connection.api.commands.execute({ sessionId, line }) + const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' }) if (!result.ok) return `${result.error.message}(${result.error.code})` - if (!result.value.matched) return `未知命令:${line}` + if (!result.value.matched) return '未知命令:/plan off' return null }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 1051d52777..aa0bf35b55 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -1,9 +1,9 @@ /** * ui-plan browser half on a real SlotsService: the plugin occupies the - * conversation-declared `conversation.input.plan` single seat with the plan - * toggle chip; the injected face executes /plan or /plan off by direction and - * folds admission outcomes into null (admitted) or a user-visible failure - * line; teardown empties the seat (HMR safety). + * conversation-declared `conversation.input.plan` single seat with the active + * plan status chip; the injected face executes /plan off and folds admission + * outcomes into null (admitted) or a user-visible failure line; teardown + * empties the seat (HMR safety). */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' @@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => { .rejects.toThrow(/slot "conversation.input.plan" is not declared/) }) - it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => { + it('registers the chip, executes /plan off, and unregisters on teardown', async () => { const b = await bench() const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -57,22 +57,20 @@ describe('ui-plan browser apply', () => { expect(entry.component).toBe(PlanChip) const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) - await expect(injected.setPlanMode(false)).resolves.toBeNull() + await expect(injected.exitPlanMode()).resolves.toBeNull() expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) - await expect(injected.setPlanMode(true)).resolves.toBeNull() - expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' }) // Business failure folds to the composer-visible line. b.execute.mockResolvedValueOnce({ result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, } as never) - await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)') + await expect(injected.exitPlanMode()).resolves.toBe('gone(session-not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. b.execute.mockResolvedValueOnce({ result: { ok: true as const, value: { matched: false as const } }, } as never) - await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan') + await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off') await fiber.dispose() expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx index 63f6bd7a6e..2489b53be0 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx @@ -1,11 +1,9 @@ // @vitest-environment jsdom /** * PlanChip over the `plan` projection: nothing renders while the capability - * is absent; with the capability present the chip renders in both states with - * aria-pressed following the effective target (pending folds — /plan shows - * pressed immediately, /plan off unpressed immediately); clicking executes - * the command toward the opposite target and surfaces direction-specific - * failures while the projection still owns the displayed state. + * is absent or the effective target is the default mode; while plan mode is + * the target, the chip executes /plan off and remains visible through failures + * until the projection confirms the exit. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -18,98 +16,74 @@ afterEach(cleanup) function setup( plan: PlanProjection | undefined, - setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null)), + exitPlanMode = vi.fn(() => Promise.resolve(null)), locked = false, ) { const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan }) const useProjection = (_key: string, selector?: (v: unknown) => unknown) => bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value)) - const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps + const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps const view = render() - return { store, setPlanMode, view } + return { store, exitPlanMode, view } } -const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) -const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' }) +const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) describe('PlanChip', () => { - it('renders nothing while the capability is absent', () => { + it('renders nothing for an absent capability or a default-mode target', () => { const absent = setup(undefined) expect(absent.view.container.innerHTML).toBe('') + cleanup() + const inactive = setup({ active: false, pending: false }) + expect(inactive.view.container.innerHTML).toBe('') + cleanup() + const leaving = setup({ active: true, pending: true }) + expect(leaving.view.container.innerHTML).toBe('') }) - it('reflects the effective target as the pressed state, folding pending', () => { - setup({ active: false, pending: false }) - expect(offChip().getAttribute('aria-pressed')).toBe('false') - cleanup() + it('renders the Plan status for active and pending-entry targets', () => { setup({ active: true, pending: false }) - expect(onChip().getAttribute('aria-pressed')).toBe('true') + expect(chip().textContent).toBe('Plan') cleanup() - // /plan just ran (command/run folded, plan/mode not yet): target is plan. setup({ active: false, pending: true }) - expect(onChip().getAttribute('aria-pressed')).toBe('true') - cleanup() - // Active with a pending exit: the target is default — already unpressed. - setup({ active: true, pending: true }) - expect(offChip().getAttribute('aria-pressed')).toBe('false') + expect(chip().textContent).toBe('Plan') }) - it('unpressed chip executes /plan (on) once and follows the projection up', async () => { + it('executes /plan off once and follows the projection down', async () => { let resolve!: (value: string | null) => void - const setPlanMode = vi.fn((_on: boolean) => new Promise((done) => { resolve = done })) - const { store } = setup({ active: false, pending: false }, setPlanMode) - fireEvent.click(offChip()) - expect(setPlanMode).toHaveBeenCalledTimes(1) - expect(setPlanMode).toHaveBeenLastCalledWith(true) - // Busy while its own call is in flight. - fireEvent.click(offChip()) - expect(setPlanMode).toHaveBeenCalledTimes(1) + const exitPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) + const { store } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) resolve(null) - // The command's run record folds: target flips, the chip presses. - store.set({ value: { active: false, pending: true } }) - await waitFor(() => { - expect(onChip().getAttribute('aria-pressed')).toBe('true') - }) - }) - - it('pressed chip executes /plan off and follows the projection down', async () => { - const setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null)) - const { store } = setup({ active: true, pending: false }, setPlanMode) - fireEvent.click(onChip()) - expect(setPlanMode).toHaveBeenLastCalledWith(false) store.set({ value: { active: true, pending: true } }) await waitFor(() => { - expect(offChip().getAttribute('aria-pressed')).toBe('false') + expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull() }) }) it('disables under the locked owner prop', () => { setup({ active: true, pending: false }, vi.fn(), true) - expect((onChip() as HTMLButtonElement).disabled).toBe(true) + expect((chip() as HTMLButtonElement).disabled).toBe(true) }) - it('surfaces direction-specific admission and transport failures while staying visible', async () => { - const exitFailing = vi.fn() + it('surfaces admission and transport failures while staying visible', async () => { + const exitPlanMode = vi.fn() .mockResolvedValueOnce('host said no') .mockRejectedValueOnce(new Error('network down')) .mockRejectedValueOnce('socket closed') - setup({ active: true, pending: false }, exitFailing) - fireEvent.click(onChip()) + setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') - expect(onChip()).toBeTruthy() + expect(chip()).toBeTruthy() - fireEvent.click(onChip()) + fireEvent.click(chip()) expect(await screen.findByTitle('network down')).toBeTruthy() - fireEvent.click(onChip()) + fireEvent.click(chip()) expect(await screen.findByTitle('socket closed')).toBeTruthy() - cleanup() - - const enterFailing = vi.fn().mockResolvedValueOnce('agent busy') - setup({ active: false, pending: false }, enterFailing) - fireEvent.click(offChip()) - expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy') - expect(offChip()).toBeTruthy() }) it('ignores in-flight fulfillment and rejection after unmount', () => { @@ -118,14 +92,14 @@ describe('PlanChip', () => { { active: true, pending: false }, vi.fn(() => new Promise((done) => { resolve = done })), ) - fireEvent.click(onChip()) + fireEvent.click(chip()) successful.view.unmount() expect(() => { resolve(null) }).not.toThrow() let reject!: (reason: unknown) => void - const setPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) - const { view } = setup({ active: true, pending: false }, setPlanMode) - fireEvent.click(onChip()) + const exitPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) + const { view } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) view.unmount() expect(() => { reject(new Error('late')) }).not.toThrow() }) diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 4ab13662f2..23ce289533 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slots" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a014fa2333..58fc06888f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1367,6 +1367,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots From 72a73078260f5d7d3f8e7518b1095609c03dc51f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:28:43 +0800 Subject: [PATCH 21/33] chore: regenerate module graph and refresh composer goldens after master merge --- apps/web/tests/snapshots/plan-review/approved.expected.md | 3 +-- apps/web/tests/snapshots/queue-actions/collapsed.expected.md | 3 +-- .../web/tests/snapshots/seeded-history/command-row.expected.md | 3 +-- docs/module-graph.md | 3 ++- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index dd62e265ee..e207392640 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -35,10 +35,9 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index cdbf6fc64b..856cdcc525 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -14,10 +14,9 @@ - paragraph: partial - button "2 条排队消息" - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 87ffd9fc55..798b2cf3de 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -38,10 +38,9 @@ - img - text: permission preset workspace-write - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/docs/module-graph.md b/docs/module-graph.md index 8e8b3130ab..da3910f978 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -927,6 +927,7 @@ flowchart TD pkg_client_ui_plan --> pkg_client_connection pkg_client_ui_plan --> pkg_client_runtime pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode @@ -1183,7 +1184,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | From 4517aa349ce34821b70ab35f1fdcdbc4d2bb6863 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 16:52:54 +0800 Subject: [PATCH 22/33] fix: client module loader report package resolve info --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 4 +- ...26-07-23-client-plugin-loading-model.zh.md | 4 +- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 + packages/client/modules/README.zh.md | 2 + packages/client/modules/src/index.ts | 53 ++++++++++-- .../client/modules/tests/node-half.spec.ts | 85 +++++++++++++++++++ 8 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 packages/client/modules/tests/node-half.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index f140786632..451dcd1fca 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d -2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c +2026-07-23-client-plugin-loading-model.md: 02347f2964942b89ec1f0a6ec483f4c2b2f9e68c +2026-07-23-client-plugin-loading-model.zh.md: ea927d35860fbbba567c47cea0ee3a45133ce0f4 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index b0873b7aa7..02347f2964 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -56,8 +56,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the **Host side — compose the graph.** -1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`. -2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports). +1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). +2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber. 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins//client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself). Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index f3472dbfc5..ea927d3586 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -56,8 +56,8 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点 **host 侧——组合这张图。** -1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获。 -2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)。 +1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。 +2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins//client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 80bf46a996..c3dfc36e65 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/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/modules/README.md -README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da -README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72 +README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8 +README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index efba9e2eb0..99565b349d 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook). +The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures. + ## Model Experience None, as the module loader is browser-side kernel machinery; nothing here reaches a model request. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index b057bfdd8c..a8ed0a4949 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -8,6 +8,8 @@ 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。 +Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。 + ## 模型体验 无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index ecfc31b77f..f7488ff2f4 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -58,6 +58,40 @@ interface PkgMeta { immediately: boolean } +/** Missing built client export, retained as structured data for activation-error grouping. */ +class MissingClientBundleError extends Error { + constructor( + readonly packageName: string, + readonly clientPath: string, + cause: unknown, + ) { + super( + `client-modules: ${packageName} needs to be built before source launch; client bundle not found at ${clientPath}`, + { cause }, + ) + } +} + +/** Activation failures grouped by actionable package-build errors and unrelated failures. */ +class ClientPackageCompositionError extends AggregateError { + constructor(failures: Error[]) { + const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError) + const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError)) + const packageNoun = failures.length === 1 ? 'package' : 'packages' + const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`] + if (missingBundles.length > 0) { + lines.push(' client packages requiring a build before source launch:') + for (const error of missingBundles) { + lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`) + } + } + if (otherFailures.length > 0) { + lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`)) + } + super(failures, lines.join('\n')) + } +} + /** One composed table row: the wire entry plus its bundle path. */ interface WebPluginRecord { entry: WebBootEntry @@ -138,7 +172,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string { * + bundle route + index tap. Construction runs the activation scan * synchronously — a malformed declaration or missing bundle among the * already-loaded entries aggregates into one loud throw (FAILED fiber; the - * boot sweep reports it). + * boot activation audit reports it). */ export class ClientModuleHostService extends Service { static inject = ['httpServer', 'loader'] @@ -194,10 +228,7 @@ export class ClientModuleHostService extends Service { const failures: Error[] = [] this.flush(err => failures.push(err)) if (failures.length > 0) { - throw new AggregateError( - failures, - `client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`, - ) + throw new ClientPackageCompositionError(failures) } ctx.effect( @@ -322,6 +353,16 @@ export class ClientModuleHostService extends Service { return meta } + /** Read the activation-time bundle revision, translating only a missing build artifact into source-launch guidance. */ + private initialBundleRevision(pkgName: string, clientPath: string): string { + try { + return shortHash(readFileSync(clientPath)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + throw new MissingClientBundleError(pkgName, clientPath, error) + } + } + /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */ private processOne(entryName: string): boolean { let qualifies = false @@ -337,7 +378,7 @@ export class ClientModuleHostService extends Service { if (meta === null) return false // The rev rides the row from here on: a fiber restart reuses the row (and // its rev) untouched; only rebuilt() re-reads the bundle. - const rev = shortHash(readFileSync(meta.clientPath)) + const rev = this.initialBundleRevision(entryName, meta.clientPath) this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath }) return true } diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts new file mode 100644 index 0000000000..37c41d9742 --- /dev/null +++ b/packages/client/modules/tests/node-half.spec.ts @@ -0,0 +1,85 @@ +/** Node-half composition diagnostics for package metadata and built client bundles. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import { afterEach, describe, expect, it } from 'vitest' +import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' +import { ClientModuleHostService } from '../src/index.ts' + +let root: string | undefined + +afterEach(() => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }) + root = undefined +}) + +/** Create a resolvable dshClient package whose client export points at the returned path. */ +function writePackage(packageName: string): string { + root ??= mkdtempSync(join(tmpdir(), 'dsh-client-modules-')) + const pkgRoot = join(root, 'node_modules', ...packageName.split('/')) + const clientPath = join(pkgRoot, 'lib', 'client.js') + mkdirSync(pkgRoot, { recursive: true }) + writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ + name: packageName, + exports: { + './client': './lib/client.js', + './package.json': './package.json', + }, + dshClient: { platform: 'web' }, + })) + return clientPath +} + +/** Construct the node-half service over the enabled fixture entries. */ +function construct(packageNames: string[]): ClientModuleHostService { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root!).href + '/' + ctx.provide('loader', { + *entries() { + for (const packageName of packageNames) { + yield { options: { name: packageName }, fiber: {}, disabled: false } + } + }, + }) + const httpServer: Pick = { + port: 0, + register: () => () => {}, + tapIndex: () => () => {}, + } + ctx.provide('httpServer', httpServer as HttpServerService) + return new ClientModuleHostService(ctx) +} + +describe('client bundle activation', () => { + it('groups missing bundles under one source-build instruction with a package/path list', () => { + const firstName = '@fixture/missing-first' + const secondName = '@fixture/missing-second' + const firstPath = writePackage(firstName) + const secondPath = writePackage(secondName) + expect(() => construct([firstName, secondName])).toThrow([ + 'client-modules: 2 client packages failed to compose:', + ' client packages requiring a build before source launch:', + ` - package: ${firstName}`, + ` path: ${firstPath}`, + ` - package: ${secondName}`, + ` path: ${secondPath}`, + ].join('\n')) + }) + + it('does not report other bundle read failures as missing builds', () => { + const packageName = '@fixture/unreadable-client' + const clientPath = writePackage(packageName) + mkdirSync(clientPath, { recursive: true }) + let thrown: unknown + try { + construct([packageName]) + } catch (error) { + thrown = error + } + expect(String(thrown)).toContain('EISDIR') + expect(String(thrown)).not.toContain('requiring a build before source launch') + }) +}) From 296cb1c35f3a27f6b0471215a4a223783a877558 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 16:53:05 +0800 Subject: [PATCH 23/33] fix: app-boot report plugin error detail --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 4 +- ...fig-tree-boot-and-transport-layering.zh.md | 4 +- apps/cli/src/app-cli-entry.ts | 4 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 6 +- packages/ui/app-boot/README.zh.md | 6 +- packages/ui/app-boot/src/index.ts | 60 +++++++++++----- packages/ui/app-boot/tests/app-boot.spec.ts | 70 ++++++++++++++++++- 9 files changed, 128 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index aa927a2e7d..dcb580be3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: a2080024d36d54162f4f4aa79896e51efd708f59 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 1c430bf2939f9f556f378bdeb78872a0091a592d +2026-07-24-web-config-tree-boot-and-transport-layering.md: 6617e0c167e39d11c2261c9b8f433f1b68185ea7 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7b693b8869613b4352d65ba7d352bd1e9fca448b diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index a2080024d3..6617e0c167 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,9 +12,9 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; `installFailLoud` remains the process guard for later unhandled rejections. -**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. +**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. **Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 1c430bf293..7b693b8869 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;`installFailLoud` 继续作为进程级保护,处理之后出现的未处理 rejection。 -**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 +**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 **每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 6ac88d23c5..54ade122c7 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -3,7 +3,7 @@ * for the Web/headless surface. * Everything here is what must exist before the Loader runs: the patch * composition over the shipped base and surface overlay (profile json + CLI - * flags + the resolved frontend dist), and the fail-loud triple after the tree + * flags + the resolved frontend dist), and the fail-loud activation audit after the tree * settles. The environment is what the bin already loaded (ambient plus the * invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential * provider and is never hoisted here. @@ -246,7 +246,7 @@ export class AppCLIEntry { if (telemetryPatch !== undefined) this.patches.push(telemetryPatch) } - /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */ + /** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */ private async bootTree(): Promise { // One include of the shared base with every overlay as a sibling patch // list: patches never cross an include boundary, so nesting them would diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index c670928ba7..4126ecee12 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7 -README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e +README.md: 7c4b258de4e5cc837ea7fa2aa8448feb421c3661 +README.zh.md: b1b12217792217bd1b29bd0752dd41afd369d5a6 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 1beffd6fbf..7c4b258de4 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -10,14 +10,14 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | -| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries | +| `assertEntriesActivated(ctx, binName)` | Await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin. +The guards preserve two Loader failure classes. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index d9ce9774b9..b1b1221779 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -10,14 +10,14 @@ | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | -| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 | +| `assertEntriesActivated(ctx, binName)` | Loader 结算后等待每个已启用条目;抛出的异常包含每个失败插件的原始 stack,或每个 pending 插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。 +这些保护保留 Loader 的两类故障。插件导入失败会留下没有 fiber 的条目,`assertEntriesLoaded` 将它转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下 failed fiber,因为 `loader.await()` 只结算生命周期任务、不向外传播该错误;`assertEntriesActivated` 显式等待 fiber,并在启动 rejection 中包含原始 stack。`installFailLoud` 仍负责拦截 boot 之后逃逸的 rejection。 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index b404809527..0351e83d65 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -192,28 +192,53 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { } } -/** Runtime mirrors for Cordis's erased const-enum fiber states. */ -const FIBER_ACTIVE = 2 as FiberState.ACTIVE +/** + * Value mirrors used because Cordis's const enum has no runtime object to import. + * Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and + * `packages/client/web/src/loader-status.ts`. + */ const FIBER_PENDING = 0 as FiberState.PENDING +const FIBER_ACTIVE = 2 as FiberState.ACTIVE + +/** Render a thrown plugin value without discarding an Error's original stack. */ +function formatActivationError(error: unknown): string { + return error instanceof Error ? error.stack ?? error.message : String(error) +} /** - * Reject enabled Loader entries whose fibers did not reach ACTIVE after settle. - * @param ctx - The settled application root. - * @param binName - Diagnostic prefix. + * Reject a settled Loader tree when an enabled entry failed or remains inactive. + * Plugin failures include the original thrown stack; pending entries name their + * unresolved services because no plugin error exists for that state. + * @param ctx - the settled context whose Loader entries to audit. + * @param binName - the diagnostic prefix on the thrown error. + * @returns nothing when every enabled entry is active. + * @throws when an entry failed to import, rejected during activation, or did not become active. */ -export function assertEntriesActive(ctx: Context, binName: string): void { +export async function assertEntriesActivated(ctx: Context, binName: string): Promise { + assertEntriesLoaded(ctx, binName) const failures: string[] = [] for (const entry of ctx.loader.entries()) { - if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue - if (entry.fiber.state === FIBER_PENDING) { - const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined) - failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) + const fiber = entry.fiber + if (fiber === undefined || entry.disabled) continue + try { + await fiber.await() + } catch (error) { + failures.push(`${entry.options.name}: ${formatActivationError(error)}`) + continue + } + const state = fiber.state + if (state === FIBER_ACTIVE) continue + if (state === FIBER_PENDING) { + const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) + const subject = missing.length === 1 ? 'service' : 'services' + failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`) } else { - failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`) + failures.push(`${entry.options.name}: fiber state ${String(state)}`) } } if (failures.length > 0) { - throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + const noun = failures.length === 1 ? 'entry' : 'entries' + throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`) } } @@ -226,8 +251,10 @@ export function assertEntriesActive(ctx: Context, binName: string): void { * `cordis:include` builtin, loading through the ambient module pipeline * (vite/tsx/plain ESM) while the included tree's own specifiers stay * config-relative. A missing fiber rejects here; a later init rejection is - * handled by {@link installFailLoud}. Built bins need the Loader's native - * helper for bare plugin specifiers; relative specifiers do not. + * rethrown with its original stack by {@link assertEntriesActivated}; later + * unhandled rejections remain covered by {@link installFailLoud}. Built bins + * need the Loader's native helper for bare plugin specifiers; relative + * specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). @@ -259,12 +286,11 @@ export async function boot( // A surface can finish and dispose the whole tree while that await is still // pending: the TUI renders as soon as its own fiber starts, so an `/exit` // typed before the last entry settles tears the context down under us. The - // Loader service goes with it, and both assertions below describe a live + // Loader service goes with it, and the activation audit describes a live // tree — reading `ctx.loader` here would throw a TypeError over an app that // exited exactly as asked. if (ctx.get('loader') === undefined) return ctx - assertEntriesLoaded(ctx, binName) - assertEntriesActive(ctx, binName) + await assertEntriesActivated(ctx, binName) return ctx } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index d6807bbaed..9a80730a1a 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, + addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' @@ -157,6 +157,74 @@ describe('assertEntriesLoaded', () => { }) }) +describe('assertEntriesActivated', () => { + interface FakeFiber { + state: number + inject: Record + ctx: { get(name: string): unknown } + await(): Promise + } + + const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({ + loader: { entries: () => entries }, + }) as unknown as Context + + const fiber = ( + state: number, + error?: unknown, + inject: Record = {}, + services: string[] = [], + ): FakeFiber => ({ + state, + inject, + ctx: { get: name => services.includes(name) ? {} : undefined }, + await: error === undefined ? async () => undefined : async () => { throw error }, + }) + + it('passes active entries and ignores disabled entries', async () => { + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(2), options: { name: 'active' } }, + { disabled: true, options: { name: 'disabled' } }, + ]), NAME)).resolves.toBeUndefined() + }) + + it('reports the plugin name and original activation stack instead of fiber state 3', async () => { + const original = new Error('actual plugin failure') + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(3, original), options: { name: 'broken-plugin' } }, + ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`) + }) + + it('formats stackless and non-Error activation failures', async () => { + const stackless = new Error('stackless failure') + delete (stackless as { stack?: string }).stack + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(3, stackless), options: { name: 'stackless' } }, + { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } }, + ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`) + }) + + it('reports unresolved services for pending entries', async () => { + const expected = [ + `${NAME}: 3 entries did not activate`, + 'waiting: pending (waiting for services: missingA, missingB)', + 'single-wait: pending (waiting for service: missing)', + 'unknown-wait: pending (waiting for services: unknown)', + ].join('\n') + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready']), options: { name: 'waiting' } }, + { fiber: fiber(0, undefined, { missing: {} }), options: { name: 'single-wait' } }, + { fiber: fiber(0), options: { name: 'unknown-wait' } }, + ]), NAME)).rejects.toThrow(expected) + }) + + it('retains the numeric diagnostic for a settled unexpected state', async () => { + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(4), options: { name: 'disposed' } }, + ]), NAME)).rejects.toThrow('disposed: fiber state 4') + }) +}) + describe('loadOverlayPatches', () => { it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => { const dir = tmp() From 890e6daa22490560779525de825e3bd75bdd7923 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 17:05:44 +0800 Subject: [PATCH 24/33] test: normalize client module fixture paths --- packages/client/modules/tests/node-half.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts index 37c41d9742..bc829ab7db 100644 --- a/packages/client/modules/tests/node-half.spec.ts +++ b/packages/client/modules/tests/node-half.spec.ts @@ -1,6 +1,6 @@ /** Node-half composition diagnostics for package metadata and built client bundles. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -18,7 +18,7 @@ afterEach(() => { /** Create a resolvable dshClient package whose client export points at the returned path. */ function writePackage(packageName: string): string { - root ??= mkdtempSync(join(tmpdir(), 'dsh-client-modules-')) + root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-'))) const pkgRoot = join(root, 'node_modules', ...packageName.split('/')) const clientPath = join(pkgRoot, 'lib', 'client.js') mkdirSync(pkgRoot, { recursive: true }) From 975cc88e4568c9f81506e6611e7b9199e1c84930 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 17:06:25 +0800 Subject: [PATCH 25/33] docs: refresh client module service catalog --- docs/cordis-catalog/services.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8044313759..af814e87f8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -328,7 +328,7 @@ Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-ba ## `ctx.clientModuleHost` — `ClientModuleHostService` -The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it). +The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). ```ts cordis-catalog /** @@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void onGraphChanged(listener: () => void): () => void ``` -Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts) +Source: [`packages/client/modules/src/index.ts:177`](../../packages/client/modules/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) From 77567b22a1a1ab3621ff256d180a03e2442ba450 Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 17:14:28 +0800 Subject: [PATCH 26/33] test: complete telemetry loader composition --- examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index d9cc454bfb..d1851ac7c9 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -8,6 +8,7 @@ - id: telemetry-redact-rule name: './telemetry-redact-rule.ts' +# Managed child-process groups required by the bash executor. - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' From a691e2ab16deb97359bf6889d5ac2810fe98997d Mon Sep 17 00:00:00 2001 From: imccyu Date: Thu, 30 Jul 2026 18:54:35 +0800 Subject: [PATCH 27/33] fix: preserve plugin activation errors --- .../activation-error.mjs | 6 +++ .../startup-activation-error/cordis.yml | 2 + .../headless-agent/tests/headless.snapshot.ts | 28 ++++++++++++ .../stderr.expected.txt | 3 ++ packages/client/modules/src/index.ts | 19 ++++++-- .../client/modules/tests/node-half.spec.ts | 6 ++- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 4 +- packages/ui/app-boot/README.zh.md | 4 +- packages/ui/app-boot/tests/app-boot.spec.ts | 43 +++++++++---------- vendor/README.md | 1 + vendor/loader/src/config/entry.ts | 5 ++- 12 files changed, 89 insertions(+), 36 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs create mode 100644 examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml create mode 100644 examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs new file mode 100644 index 0000000000..16e5858045 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs @@ -0,0 +1,6 @@ +/** Fail activation with a deterministic stack so the user-visible startup diagnostic is snapshot-stable. */ +export function apply() { + const failure = new Error('startup activation snapshot failure') + failure.stack = 'Error: startup activation snapshot failure\n at activation-error-fixture' + throw failure +} diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml new file mode 100644 index 0000000000..2738e4a924 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml @@ -0,0 +1,2 @@ +- id: activation-error + name: ./activation-error.mjs diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 4217f24228..06d42ac9cc 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -33,6 +33,8 @@ const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) +const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) +const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) @@ -167,6 +169,32 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('prints the original Loader activation error through the assembled one-shot app', async () => { + const label = 'headless startup activation error snapshot' + let failure: unknown + try { + await runLoaderSmoke({ + label, + tempDirPrefix: 'headless-snapshot-startup-error-', + binScript, + configPath: startupFailureConfigPath, + binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'], + tsconfigPath, + }) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(Error) + const message = (failure as Error).message + const prefix = `${label} exited 1. stdout:\n` + const stderrMarker = '\nstderr:\n' + expect(message.startsWith(prefix)).toBe(true) + const stderrAt = message.indexOf(stderrMarker, prefix.length) + expect(stderrAt).toBeGreaterThanOrEqual(prefix.length) + expect(message.slice(prefix.length, stderrAt)).toBe('') + await expect(message.slice(stderrAt + stderrMarker.length)).toMatchFileSnapshot(startupFailureExpected) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('retries a transient provider failure through the one-shot app', async () => { const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry') const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl') diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt new file mode 100644 index 0000000000..5896d03464 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt @@ -0,0 +1,3 @@ +dsh-cli-demo: dsh-cli-demo: 1 entry did not activate +./activation-error.mjs: Error: startup activation snapshot failure + at activation-error-fixture diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index f7488ff2f4..694295e7f2 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -58,6 +58,9 @@ interface PkgMeta { immediately: boolean } +/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */ +const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch' + /** Missing built client export, retained as structured data for activation-error grouping. */ class MissingClientBundleError extends Error { constructor( @@ -66,7 +69,11 @@ class MissingClientBundleError extends Error { cause: unknown, ) { super( - `client-modules: ${packageName} needs to be built before source launch; client bundle not found at ${clientPath}`, + [ + `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`, + ` package: ${packageName}`, + ` path: ${clientPath}`, + ].join('\n'), { cause }, ) } @@ -80,7 +87,7 @@ class ClientPackageCompositionError extends AggregateError { const packageNoun = failures.length === 1 ? 'package' : 'packages' const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`] if (missingBundles.length > 0) { - lines.push(' client packages requiring a build before source launch:') + lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`) for (const error of missingBundles) { lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`) } @@ -353,7 +360,13 @@ export class ClientModuleHostService extends Service { return meta } - /** Read the activation-time bundle revision, translating only a missing build artifact into source-launch guidance. */ + /** + * Read the activation-time bundle revision. + * @param pkgName - package that declares the client bundle. + * @param clientPath - absolute path of the built client artifact. + * @returns the bundle content's short hash for use as its revision. + * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged. + */ private initialBundleRevision(pkgName: string, clientPath: string): string { try { return shortHash(readFileSync(clientPath)) diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts index bc829ab7db..3eb99c0ead 100644 --- a/packages/client/modules/tests/node-half.spec.ts +++ b/packages/client/modules/tests/node-half.spec.ts @@ -61,7 +61,7 @@ describe('client bundle activation', () => { const secondPath = writePackage(secondName) expect(() => construct([firstName, secondName])).toThrow([ 'client-modules: 2 client packages failed to compose:', - ' client packages requiring a build before source launch:', + ' client bundles not found; run `pnpm run build` before launch:', ` - package: ${firstName}`, ` path: ${firstPath}`, ` - package: ${secondName}`, @@ -79,7 +79,9 @@ describe('client bundle activation', () => { } catch (error) { thrown = error } + expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:') + expect(String(thrown)).toContain(' other failures:') expect(String(thrown)).toContain('EISDIR') - expect(String(thrown)).not.toContain('requiring a build before source launch') + expect(String(thrown)).not.toContain('pnpm run build') }) }) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 4126ecee12..2cbbfa4f4c 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 7c4b258de4e5cc837ea7fa2aa8448feb421c3661 -README.zh.md: b1b12217792217bd1b29bd0752dd41afd369d5a6 +README.md: 55211988a7687ba52f13d30931e042f0823e2526 +README.zh.md: 92e2ba56096d2a48d20bfaf37f77cd12e671dcf6 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 7c4b258de4..55211988a7 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -10,14 +10,14 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | -| `assertEntriesActivated(ctx, binName)` | Await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | +| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -The guards preserve two Loader failure classes. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot. +Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b1b1221779..92e2ba5609 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -10,14 +10,14 @@ | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | -| `assertEntriesActivated(ctx, binName)` | Loader 结算后等待每个已启用条目;抛出的异常包含每个失败插件的原始 stack,或每个 pending 插件尚未解析的服务 | +| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -这些保护保留 Loader 的两类故障。插件导入失败会留下没有 fiber 的条目,`assertEntriesLoaded` 将它转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下 failed fiber,因为 `loader.await()` 只结算生命周期任务、不向外传播该错误;`assertEntriesActivated` 显式等待 fiber,并在启动 rejection 中包含原始 stack。`installFailLoud` 仍负责拦截 boot 之后逃逸的 rejection。 +Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。`installFailLoud` 继续作为进程级保护,处理启动后逃逸的 rejection。 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 9a80730a1a..8edbb264cd 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -300,31 +300,28 @@ describe('boot', () => { await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) }) - it('rejects a settled tree with a pending inject and names every missing service', async () => { + it('reports an activation error from a real Loader fiber instead of its numeric state', async () => { const dir = tmp() - writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n") + writeFileSync(join(dir, 'broken.mjs'), 'export function apply() { throw new Error("real activation failure") }\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: broken\n name: ./broken.mjs\n') + let thrown: unknown + try { + await boot(NAME, join(dir, 'cordis.yml')) + } catch (error) { + thrown = error + } + expect(String(thrown)).toContain(`${NAME}: 1 entry did not activate\n./broken.mjs: Error: real activation failure`) + expect(String(thrown)).not.toContain('fiber state 3') + }) + + it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { + const dir = tmp() + writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n') writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n') - await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)') - }) - - it('uses singular diagnostics for one missing pending dependency', () => { - const ctx = { - loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] }, - get: () => undefined, - } as unknown as Context - expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)') - }) - - it('reports unknown pending dependencies and unexpected fiber states', () => { - const entries = [ - { disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } }, - { disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } }, - ] - const ctx = { - loader: { entries: () => entries }, - get: () => undefined, - } as unknown as Context - expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`) + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([ + `${NAME}: 1 entry did not activate`, + './waiting.mjs: pending (waiting for service: neverProvided)', + ].join('\n')) }) }) diff --git a/vendor/README.md b/vendor/README.md index b140c057ab..8b34e14b47 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,6 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +10. **`loader/src/config/entry.ts` activation observer**: observes both fulfillment and rejection before notifying Loader reflection. Upstream's `fiber.await().finally(...)` leaves a rejected derived promise unhandled when plugin activation fails, so Node can terminate before the host reads the original error from the fiber. Covered through the real Loader by `packages/ui/app-boot/tests/app-boot.spec.ts`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index c2959fe61e..a8821185c2 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -160,10 +160,11 @@ export class Entry { } finally { this._initTask = undefined } - this.fiber?.await().finally(() => { + const notify = () => { if (this.loader.getTasks().length) return this.ctx.reflect.notify(['loader']) - }) + } + void this.fiber?.await().then(notify, notify) } private async _init() { From e423d1011190894f82fe8e447b706dfce011746f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:30:55 +0800 Subject: [PATCH 28/33] fix: ci --- docs/config-catalog.md | 2 ++ docs/cordis-catalog/services.md | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f1d901d326..243a4cc28b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -569,6 +569,8 @@ export interface Config { host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number + /** EADDRINUSE handling: fail immediately or try consecutive ports through 65535. */ + portConflict: 'fail' | 'increment' /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ distIndex: string } diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index af814e87f8..1ecffa4426 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void onGraphChanged(listener: () => void): () => void ``` -Source: [`packages/client/modules/src/index.ts:177`](../../packages/client/modules/src/index.ts) +Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) @@ -739,7 +739,7 @@ Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/ind ## `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. +The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). Listen conflict handling follows the explicit config policy; every unhandled listen failure throws out of init — a FAILED fiber the boot's activation audit reports. ```ts cordis-catalog /** @@ -759,7 +759,7 @@ register(route: WebRoute): () => void tapIndex(transform: (html: string) => string): () => void ``` -Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:58`](../../packages/host/webserver/src/index.ts) ## `ctx.invariants` — `InvariantService` From c92a6c3b7697c050c60b2d4b1bfa3435efea53ed Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:35:53 +0800 Subject: [PATCH 29/33] fix(app-boot): preserve published loader composition --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- .../headless-agent/tests/headless.snapshot.ts | 34 ++++-------- .../tests/loader-composition.spec.ts | 1 + packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 4 +- packages/ui/app-boot/README.zh.md | 4 +- packages/ui/app-boot/src/index.ts | 54 ++++++++++++++++--- packages/ui/app-boot/tests/app-boot.spec.ts | 41 +++++++++----- packages/ui/app-boot/tsdown.config.ts | 19 +++++++ vendor/README.md | 1 - vendor/loader/src/config/entry.ts | 5 +- 13 files changed, 116 insertions(+), 59 deletions(-) create mode 100644 packages/ui/app-boot/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index dcb580be3b..8ac6ef31e0 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 6617e0c167e39d11c2261c9b8f433f1b68185ea7 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7b693b8869613b4352d65ba7d352bd1e9fca448b +2026-07-24-web-config-tree-boot-and-transport-layering.md: 0aec4714238ed71c6878ce723b55fa7a7434af89 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: b20d29e3265c91aa54eb0b274c1f95353460f46e diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 6617e0c167..0aec471423 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; `installFailLoud` remains the process guard for later unhandled rejections. +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 7b693b8869..b20d29e326 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;`installFailLoud` 继续作为进程级保护,处理之后出现的未处理 rejection。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 06d42ac9cc..8b48165a83 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -170,29 +170,17 @@ async function persistedLogs(cwd: string): Promise { describe('headless stream-json snapshots', () => { it('prints the original Loader activation error through the assembled one-shot app', async () => { - const label = 'headless startup activation error snapshot' - let failure: unknown - try { - await runLoaderSmoke({ - label, - tempDirPrefix: 'headless-snapshot-startup-error-', - binScript, - configPath: startupFailureConfigPath, - binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'], - tsconfigPath, - }) - } catch (error) { - failure = error - } - expect(failure).toBeInstanceOf(Error) - const message = (failure as Error).message - const prefix = `${label} exited 1. stdout:\n` - const stderrMarker = '\nstderr:\n' - expect(message.startsWith(prefix)).toBe(true) - const stderrAt = message.indexOf(stderrMarker, prefix.length) - expect(stderrAt).toBeGreaterThanOrEqual(prefix.length) - expect(message.slice(prefix.length, stderrAt)).toBe('') - await expect(message.slice(stderrAt + stderrMarker.length)).toMatchFileSnapshot(startupFailureExpected) + const result = await runLoaderSmoke({ + label: 'headless startup activation error snapshot', + tempDirPrefix: 'headless-snapshot-startup-error-', + binScript, + configPath: startupFailureConfigPath, + binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'], + tsconfigPath, + expectedExitCode: 1, + }) + expect(result.stdout).toBe('') + await expect(result.stderr).toMatchFileSnapshot(startupFailureExpected) }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('retries a transient provider failure through the one-shot app', async () => { diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index ce86a8d4ec..59ca1c1992 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -56,6 +56,7 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx ' config:', ` host: '${bindHost}'`, ' port: 0', + ' portConflict: increment', ` distIndex: '${distIndex}'`, `- name: '${AUTO}'`, '', diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 2cbbfa4f4c..684f322477 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 55211988a7687ba52f13d30931e042f0823e2526 -README.zh.md: 92e2ba56096d2a48d20bfaf37f77cd12e671dcf6 +README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3 +README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 55211988a7..54f754842d 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -17,9 +17,9 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. `installFailLoud` remains the process guard for rejections that escape after boot. +Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 92e2ba5609..dd56084812 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -17,9 +17,9 @@ | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。`installFailLoud` 继续作为进程级保护,处理启动后逃逸的 rejection。 +Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 0351e83d65..e18346d166 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -160,16 +160,46 @@ export interface FailLoudProcess { exit(code: number): void } +// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep +// exact reasons already folded into the boot diagnostic visible through the +// next process rejection checkpoint so the process guard can coalesce them. +const assembledActivationRejections = new Map() + +function retainAssembledRejection(reason: unknown): void { + assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1) +} + +function releaseAssembledRejection(reason: unknown): void { + const count = assembledActivationRejections.get(reason) + if (count === undefined || count === 1) { + assembledActivationRejections.delete(reason) + } else { + assembledActivationRejections.set(reason, count - 1) + } +} + +async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise { + for (const reason of reasons) retainAssembledRejection(reason) + try { + await new Promise(resolve => setImmediate(resolve)) + } finally { + for (const reason of reasons) releaseAssembledRejection(reason) + } +} + /** * Install before boot to turn a late unhandled plugin-init rejection into one - * labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP; - * the returned function removes the handler. + * labelled stderr diagnostic and `exit(1)`. A rejection already included by + * {@link assertEntriesActivated} is ignored during its process checkpoint; + * every other rejection remains fatal. Stdout remains untouched for ACP; the + * returned function removes the handler. * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. * @returns the uninstaller that removes the rejection handler. */ export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void { const handler = (err: unknown): void => { + if (assembledActivationRejections.has(err)) return proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) proc.exit(1) } @@ -212,17 +242,20 @@ function formatActivationError(error: unknown): string { * @param ctx - the settled context whose Loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. * @returns nothing when every enabled entry is active. - * @throws when an entry failed to import, rejected during activation, or did not become active. + * @throws after one process rejection checkpoint when an entry failed to + * import, rejected during activation, or did not become active. */ export async function assertEntriesActivated(ctx: Context, binName: string): Promise { assertEntriesLoaded(ctx, binName) const failures: string[] = [] + const rejectionReasons: unknown[] = [] for (const entry of ctx.loader.entries()) { const fiber = entry.fiber if (fiber === undefined || entry.disabled) continue try { await fiber.await() } catch (error) { + rejectionReasons.push(error) failures.push(`${entry.options.name}: ${formatActivationError(error)}`) continue } @@ -237,6 +270,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro } } if (failures.length > 0) { + if (rejectionReasons.length > 0) { + await observeLoaderRejectionCheckpoint(rejectionReasons) + } const noun = failures.length === 1 ? 'entry' : 'entries' throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`) } @@ -250,11 +286,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * bootstrap include is therefore statically imported and mounted as the * `cordis:include` builtin, loading through the ambient module pipeline * (vite/tsx/plain ESM) while the included tree's own specifiers stay - * config-relative. A missing fiber rejects here; a later init rejection is - * rethrown with its original stack by {@link assertEntriesActivated}; later - * unhandled rejections remain covered by {@link installFailLoud}. Built bins - * need the Loader's native helper for bare plugin specifiers; relative - * specifiers do not. + * config-relative. The package build embeds Include while leaving Loader + * external, so the built include tree and host share one Loader peer. A + * missing fiber rejects here; a later init rejection is rethrown with its + * original stack by {@link assertEntriesActivated}; later unhandled + * rejections remain covered by {@link installFailLoud}. Built bins need the + * Loader's native helper for bare plugin specifiers; relative specifiers do + * not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 8edbb264cd..1866e50076 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -135,6 +135,33 @@ describe('installFailLoud', () => { uninstallReal() expect(process.listenerCount('unhandledRejection')).toBe(before) }) + + it('does not report an activation rejection shared by entries in the boot audit', async () => { + const proc = fakeProc() + installFailLoud(NAME, proc) + const error = new Error('assembled activation failure') + const audit = assertEntriesActivated({ + loader: { + entries: () => ['broken-a', 'broken-b'].map(name => ({ + options: { name }, + fiber: { + state: 3, + inject: {}, + ctx: { get: () => undefined }, + await: async () => { throw error }, + }, + })), + }, + } as unknown as Context, NAME) + await Promise.resolve() + await Promise.resolve() + proc.handlers[0]!(error) + expect(proc.written).toEqual([]) + expect(proc.exits).toEqual([]) + await expect(audit).rejects.toThrow('assembled activation failure') + proc.handlers[0]!(error) + expect(proc.exits).toEqual([1]) + }) }) describe('assertEntriesLoaded', () => { @@ -300,20 +327,6 @@ describe('boot', () => { await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) }) - it('reports an activation error from a real Loader fiber instead of its numeric state', async () => { - const dir = tmp() - writeFileSync(join(dir, 'broken.mjs'), 'export function apply() { throw new Error("real activation failure") }\n') - writeFileSync(join(dir, 'cordis.yml'), '- id: broken\n name: ./broken.mjs\n') - let thrown: unknown - try { - await boot(NAME, join(dir, 'cordis.yml')) - } catch (error) { - thrown = error - } - expect(String(thrown)).toContain(`${NAME}: 1 entry did not activate\n./broken.mjs: Error: real activation failure`) - expect(String(thrown)).not.toContain('fiber state 3') - }) - it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { const dir = tmp() writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n') diff --git a/packages/ui/app-boot/tsdown.config.ts b/packages/ui/app-boot/tsdown.config.ts new file mode 100644 index 0000000000..88492d7c26 --- /dev/null +++ b/packages/ui/app-boot/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown' + +/** + * Embed Include while keeping Loader external so the built include tree and + * app host bind to one Loader peer. + */ +export default defineConfig({ + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + deps: { + alwaysBundle: ['@cordisjs/plugin-include'], + }, +}) diff --git a/vendor/README.md b/vendor/README.md index 8b34e14b47..b140c057ab 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,6 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -10. **`loader/src/config/entry.ts` activation observer**: observes both fulfillment and rejection before notifying Loader reflection. Upstream's `fiber.await().finally(...)` leaves a rejected derived promise unhandled when plugin activation fails, so Node can terminate before the host reads the original error from the fiber. Covered through the real Loader by `packages/ui/app-boot/tests/app-boot.spec.ts`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index a8821185c2..c2959fe61e 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -160,11 +160,10 @@ export class Entry { } finally { this._initTask = undefined } - const notify = () => { + this.fiber?.await().finally(() => { if (this.loader.getTasks().length) return this.ctx.reflect.notify(['loader']) - } - void this.fiber?.await().then(notify, notify) + }) } private async _init() { From 2dab9667f49a0ca1a3174f96e3aa69e863231871 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:29:05 +0800 Subject: [PATCH 30/33] fix: ci --- docs/config-catalog.md | 2 -- docs/cordis-catalog/services.md | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 243a4cc28b..f1d901d326 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -569,8 +569,6 @@ export interface Config { host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number - /** EADDRINUSE handling: fail immediately or try consecutive ports through 65535. */ - portConflict: 'fail' | 'increment' /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ distIndex: string } diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1ecffa4426..8eed1dab5d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -739,7 +739,7 @@ Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/ind ## `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). Listen conflict handling follows the explicit config policy; every unhandled listen failure throws out of init — a FAILED fiber the boot's activation audit reports. +The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. ```ts cordis-catalog /** @@ -759,7 +759,7 @@ register(route: WebRoute): () => void tapIndex(transform: (html: string) => string): () => void ``` -Source: [`packages/host/webserver/src/index.ts:58`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:55`](../../packages/host/webserver/src/index.ts) ## `ctx.invariants` — `InvariantService` From 05d73d657103eeaa4667468147c6066b77ae60d1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:19:48 +0800 Subject: [PATCH 31/33] fix(app-boot): await only failed activation fibers --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +-- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 21 +++++++----- packages/ui/app-boot/tests/app-boot.spec.ts | 33 ++++++++++++++++--- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 8ac6ef31e0..d50428d5ed 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 0aec4714238ed71c6878ce723b55fa7a7434af89 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: b20d29e3265c91aa54eb0b274c1f95353460f46e +2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 0aec471423..88f94b1f58 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits settled fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index b20d29e326..ea2a8f70a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、等待 settled fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index e18346d166..015898f2cb 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -229,6 +229,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { */ const FIBER_PENDING = 0 as FiberState.PENDING const FIBER_ACTIVE = 2 as FiberState.ACTIVE +const FIBER_FAILED = 3 as FiberState.FAILED /** Render a thrown plugin value without discarding an Error's original stack. */ function formatActivationError(error: unknown): string { @@ -238,7 +239,9 @@ function formatActivationError(error: unknown): string { /** * Reject a settled Loader tree when an enabled entry failed or remains inactive. * Plugin failures include the original thrown stack; pending entries name their - * unresolved services because no plugin error exists for that state. + * unresolved services because no plugin error exists for that state. Active + * entries require no further wait; only failed fibers are awaited to recover + * their private rejection reason. * @param ctx - the settled context whose Loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. * @returns nothing when every enabled entry is active. @@ -252,15 +255,17 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro for (const entry of ctx.loader.entries()) { const fiber = entry.fiber if (fiber === undefined || entry.disabled) continue - try { - await fiber.await() - } catch (error) { - rejectionReasons.push(error) - failures.push(`${entry.options.name}: ${formatActivationError(error)}`) - continue - } const state = fiber.state if (state === FIBER_ACTIVE) continue + if (state === FIBER_FAILED) { + try { + await fiber.await() + } catch (error) { + rejectionReasons.push(error) + failures.push(`${entry.options.name}: ${formatActivationError(error)}`) + } + continue + } if (state === FIBER_PENDING) { const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) const subject = missing.length === 1 ? 'service' : 'services' diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 1866e50076..c756f28b51 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -209,10 +209,22 @@ describe('assertEntriesActivated', () => { }) it('passes active entries and ignores disabled entries', async () => { + let awaitCalls = 0 + const active = fiber(2) + active.await = async () => { + awaitCalls++ + return undefined + } + const disabled = fiber(3, new Error('disabled failure')) + disabled.await = async () => { + awaitCalls++ + throw new Error('disabled failure') + } await expect(assertEntriesActivated(ctxWith([ - { fiber: fiber(2), options: { name: 'active' } }, - { disabled: true, options: { name: 'disabled' } }, + { fiber: active, options: { name: 'active' } }, + { fiber: disabled, disabled: true, options: { name: 'disabled' } }, ]), NAME)).resolves.toBeUndefined() + expect(awaitCalls).toBe(0) }) it('reports the plugin name and original activation stack instead of fiber state 3', async () => { @@ -232,17 +244,28 @@ describe('assertEntriesActivated', () => { }) it('reports unresolved services for pending entries', async () => { + let awaitCalls = 0 const expected = [ `${NAME}: 3 entries did not activate`, 'waiting: pending (waiting for services: missingA, missingB)', 'single-wait: pending (waiting for service: missing)', 'unknown-wait: pending (waiting for services: unknown)', ].join('\n') + const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready']) + const singleWait = fiber(0, undefined, { missing: {} }) + const unknownWait = fiber(0) + for (const item of [waiting, singleWait, unknownWait]) { + item.await = async () => { + awaitCalls++ + return undefined + } + } await expect(assertEntriesActivated(ctxWith([ - { fiber: fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready']), options: { name: 'waiting' } }, - { fiber: fiber(0, undefined, { missing: {} }), options: { name: 'single-wait' } }, - { fiber: fiber(0), options: { name: 'unknown-wait' } }, + { fiber: waiting, options: { name: 'waiting' } }, + { fiber: singleWait, options: { name: 'single-wait' } }, + { fiber: unknownWait, options: { name: 'unknown-wait' } }, ]), NAME)).rejects.toThrow(expected) + expect(awaitCalls).toBe(0) }) it('retains the numeric diagnostic for a settled unexpected state', async () => { From 5ccebb4741199c3f4c51704703f547d3eb61d36b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:46:22 +0800 Subject: [PATCH 32/33] test(web): align the search e2e anchor with the localized placeholder --- apps/web/tests/navigation-panes.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 8de890a726..2b98d338d2 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -98,7 +98,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) - const search = page.getByPlaceholder('Search names or content', { exact: false }) + const search = page.getByPlaceholder('Search name, keywords', { exact: false }) // The cold row has not been opened, so only the persisted log can satisfy // this query. First search lazily reconciles the SQLite content index. await search.fill('zzzqx-no-such-session') From b10bcf2d442735d8be20c11974b7d896e692368b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:56:51 +0800 Subject: [PATCH 33/33] test(web): re-record the hero golden for the localized search placeholder --- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 78abf19365..70424a4c8c 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -11,7 +11,7 @@ - img - button "Search sessions": - img -- textbox "Search names or content…" +- textbox "Search name, keywords..." - tree "Sessions": - treeitem "workspace 1 session" [expanded]: - img