diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 2c2fc1b908..f53d8aec09 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: 0ff8cbf50c18285092432850ce5222430f5acf0d -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f3fca078d089a9ee1e329fdbb661653053053a91 +2026-08-25-session-observations-and-projection-owned-client-state.md: 8573ea89fae6f52a9d28cc75261aea529df4f075 +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 55c477e994df72ef8f6b6e4bb97b8b35e5783c9b diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 0ff8cbf50c..8573ea89fa 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -108,11 +108,11 @@ These distinctions prevent one overloaded `undefined` from representing cache mi | Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent | | Projection frame | One whole key | Event sequence carried by the frame | Not applicable | -The Client stores one row per key with its sequence number. A newer hint, baseline, or frame replaces a row; an equal or older input is ignored. Reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence. +The Client stores one row per key. A row the connected Host computed carries its sequence number: a newer baseline or frame replaces it, an equal or older input is ignored, and reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence. A list hint viewed from the persisted cache carries no comparable sequence number and yields to every Host-sequenced write ([projection cache listing identity and cached rows](2026-09-19-projection-cache-listing-identity-and-cached-rows.md)). The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority. -The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames under one higher-sequence-wins rule. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer row. +The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames. Higher-sequence-wins applies among Host-sequenced values; cached list hints sit below all of them. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer sequenced row. Data that is not derived from one Session remains outside projections. `session/modelCatalog` owns the Host-generation model catalog, and `agentPresets/list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index f3fca078d0..55c477e994 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -108,11 +108,11 @@ Projection 的三种交付状态含义不同: | Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 | | Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 | -Client 为每个 key 保存带 sequence number 的一行。更新的 hint、baseline 或 frame 会替换 row;相同或更旧的输入被忽略。因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。 +Client 为每个 key 保存一行。由建连 Host 算出的行带 sequence number:更新的 baseline 或 frame 会替换它,相同或更旧的输入被忽略,因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。从持久化缓存看出来的 list hint 不带可比的 sequence number,让位于所有 Host 排序过的写入([Projection cache 只读面按 lifecycle 身份匹配,客户端 store 区分 cached 与 sequenced 行](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。 List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。 -每个 Session 的 Client projection store 按一条 higher-sequence-wins 规则接收 list hints、follow baseline 和后续 whole-value frame。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 row。 +每个 Session 的 Client projection store 接收 list hints、follow baseline 和后续 whole-value frame。higher-sequence-wins 只在 Host 排序过的值之间生效;cached list hint 排在它们全部之下。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 sequenced row。 不由单个 Session 派生的数据不进入 projection。`session/modelCatalog` 持有当前 Host generation 的 model catalog,`agentPresets/list` 持有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。 diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml index ebe52594b7..87f3b7c351 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.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-09-02-projcache-cross-version-read-compat.md -2026-09-02-projcache-cross-version-read-compat.md: 82f7f26d5cd0484f5adcd51db17748f41a298294 -2026-09-02-projcache-cross-version-read-compat.zh.md: 58705ec8f15fbffff6af31872a69f1f19ef5e3c5 +2026-09-02-projcache-cross-version-read-compat.md: 160c5c796bacc6f672b06acc0df4246d7008c52b +2026-09-02-projcache-cross-version-read-compat.zh.md: cff7742f6c1f887b905a2c139e4157782aef677c diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md index 82f7f26d5c..160c5c796b 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md @@ -39,7 +39,7 @@ Declared read compatibility — reads tolerate vouched-for older versions, write 5. **Identity matching is stricter than structural admission**: an absent `formatVersion` never matches a current Session, so predecessor rows cannot seed a projection and refold from the authoritative log. Once the format matches, `identityMatches` normalizes absent lineage to unseeded (`?? false` / `?? 0`): exact for an unseeded session, while a seeded expectation fails the match. Poisoned v5 homes therefore boot safely, but their unbound rows are not exposed as current values. 6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `.json.bak.`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). For this domain it supersedes the reset/destroy recovery path of the [2026-07-28 storage recovery proposal](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md), which stays live for authoritative and whole-medium damage. -7. **A predecessor title is a listing hint, not a fold shortcut**: Session-list startup remains metadata/cache-only and never opens cold log bodies. The log header is authoritative; a lifecycle-matching checkpoint is a durable-prefix witness that may lag but cannot lead the log. `cachedPredecessorTitle` therefore exposes only a predecessor `title` row that still passes the current title unit's `stateVersion` and schema. Both adjacent Session-format edges preserve title text. The hint uses `asOfSeq: -1` rather than the stored row sequence because a cardinality-changing log migration can remap that coordinate. Other predecessor rows remain hidden, and `hydratePrepared`/`coldSnapshot` retain strict format identity, because normalizers can change values such as `blank` or `lastPromptAt` even when storage is physically consistent. +7. **A predecessor title is a listing hint, not a fold shortcut**: Session-list startup remains metadata/cache-only and never opens cold log bodies. The log header is authoritative; a lifecycle-matching checkpoint is a durable-prefix witness that may lag but cannot lead the log. `cachedPredecessorTitle` therefore exposes only a predecessor `title` row that still passes the current title unit's `stateVersion` and schema. Both adjacent Session-format edges preserve title text. The hint carries the stored title row's own watermark as `asOfSeq`; because a cardinality-changing log migration can remap that coordinate, the Session list labels the block `kind: 'cached'` and the client never compares it with the connected Session's seqs ([listing identity and cached rows](2026-09-19-projection-cache-listing-identity-and-cached-rows.md)). Other predecessor rows remain hidden, and `hydratePrepared`/`coldSnapshot` retain strict format identity, because normalizers can change values such as `blank` or `lastPromptAt` even when storage is physically consistent. ### v3-v6 → v7 disposition diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md index 58705ec8f1..cff7742f6c 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md @@ -39,7 +39,7 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;v6 只 5. **identity 匹配比结构准入更严格**:缺失 `formatVersion` 的记录绝不匹配当前 Session,因此前代行不能播种投影,而会从权威日志重新折叠。格式匹配后,`identityMatches` 才把缺失 lineage 归一化为 unseeded(`?? false` / `?? 0`):对 unseeded 会话精确,对 seeded 期望则匹配失败。v5 投毒 home 因而可以安全启动,但其未绑定行不会作为当前值暴露。 6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `.json.bak.`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。对本域而言,该策略取代了 [2026-07-28 存储恢复提案](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md)中 reset/destroy 的恢复途径;该提案对权威介质与整介质损坏仍然有效。 -7. **predecessor title 是列表 hint,而不是 fold shortcut**:Session list 启动保持 metadata/cache-only,绝不打开冷 log body。log header 是权威来源;生命周期匹配的 checkpoint 是 durable prefix witness,可以落后但不能领先日志。因此 `cachedPredecessorTitle` 只公开仍通过当前 title unit `stateVersion` 与 schema 的 predecessor `title` row。两条相邻 Session format edge 都保留 title 文本。该 hint 使用 `asOfSeq: -1`,而不使用存储 row 的序号,因为改变日志事件数量的迁移会重新映射该坐标。其他 predecessor row 继续隐藏,`hydratePrepared`/`coldSnapshot` 仍要求严格格式 identity,因为即使物理存储一致,normalizer 仍可能改变 `blank` 或 `lastPromptAt` 等值。 +7. **predecessor title 是列表 hint,而不是 fold shortcut**:Session list 启动保持 metadata/cache-only,绝不打开冷 log body。log header 是权威来源;生命周期匹配的 checkpoint 是 durable prefix witness,可以落后但不能领先日志。因此 `cachedPredecessorTitle` 只公开仍通过当前 title unit `stateVersion` 与 schema 的 predecessor `title` row。两条相邻 Session format edge 都保留 title 文本。该 hint 的 `asOfSeq` 是存储 title row 自己的水位;因为改变日志事件数量的迁移会重新映射该坐标,Session list 把该 block 标为 `kind: 'cached'`,客户端不会拿它与建连 Session 的 seq 比较([只读面按 lifecycle 身份匹配与 cached 行](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。其他 predecessor row 继续隐藏,`hydratePrepared`/`coldSnapshot` 仍要求严格格式 identity,因为即使物理存储一致,normalizer 仍可能改变 `blank` 或 `lastPromptAt` 等值。 ### v3-v6 → v7 处置 diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml index 94b8f7c295..0a33e19e2c 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.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-09-15-client-session-references.md -2026-09-15-client-session-references.md: 00f87cfb6f358218060a72199888690c3e46a091 -2026-09-15-client-session-references.zh.md: 7947a8a405d295eb6324ac33f06c0f2bc062c91b +2026-09-15-client-session-references.md: 162ef08875e5b0591b3b4099c9bdcadb18e7d175 +2026-09-15-client-session-references.zh.md: 3af8932f7c002b3b63de21c689ae91e3017a164e diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md index 00f87cfb6f..162ef08875 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md +++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md @@ -73,7 +73,7 @@ The Controller owns source counts alongside each live generation's references. F Each available Session list row exposes a read-only `retainedBy` record from source key to positive reference count. An unretained row has an empty record; zero-count keys are absent. Acquisition failure and release update that projection, including removal of the final key. The source counts remain local facts when Host metadata is refreshed; Host responses cannot overwrite them. -The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` synthesizes a local fallback row for every live generation so Provider and ownership consumers can resolve it; `ids` remains the Host-list membership and ordering. A fallback row is not Host metadata. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host. +The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` contains Host summaries, projected child-catalog rows, and fallback rows for retained subagents with a known direct-parent address. Acquisition publishes a missing subagent row synchronously so Provider discovery does not depend on parent-catalog arrival; title projections and reference-source counts continue to update that row. Ordinary retained Sessions do not synthesize fallback rows. `ids` remains the Host-list membership and ordering. Consumers of an unlisted generation use its binding and `retainInfo` directly. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host. For example, `retainedBy = { mainView: 1, gateway: 2 }` means that the main view and two Host invocations own three references. Releasing the main-view reference removes only `mainView`; the Gateway invocations continue to own the generation. Source names in this example are consumer keys, not a closed Controller enum. @@ -141,7 +141,7 @@ Scoped business objects capture their binding before awaiting work. They do not ### Main-area navigation and presentation -`uiWorkspace.openSession`, `openWorkspace`, `forkSession`, and `startSession` remain the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership. +`uiWorkspace.openSession`, `openWorkspace`, and `startSession` are the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. Sidebar `forkSession` creates and renames the child without retaining it or changing selection. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership. The existing `uiWorkspace` implementation directly owns the main-area reference and target. Its navigation methods update that owner rather than calling a receiver registered by Conversation. `ui-session` derives the root Provider binding from the source marker; the main Conversation and associated right Sidebar only inherit the Provider and neither depend on `uiWorkspace` nor see the main reference. An independent Sidebar Conversation establishes a nested Provider from its own reference and overrides only that subtree's binding. The main reference is not a global standard prop, subtree Hook, or default value for ID-based lookup. @@ -160,7 +160,7 @@ Source metadata does not change when DOM focus moves or when a global panel hide Conversation retains its `hero`, `settling`, and `active` composition and existing history-loading and `openError` handling. Acquisition adds no outer loading/error phase presentation, extra composer-hiding condition, Retry button, or replacement Sidebar recovery panel. Existing error handlers continue to handle their errors; call sites without error presentation gain none. Promise rejection and correct reference release do not imply an additional UI handler. -Workspace connection and fork preserve their existing navigation guards and panel-switch invalidation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects. +Workspace connection preserves its navigation guards and panel-switch invalidation. Sidebar fork does not supersede an in-flight navigation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects. ### Presets and creation flows @@ -221,6 +221,6 @@ Consumers must keep references until their real completion point; an unreleased Final release discards non-persisted binding-owned state, including loaded history pages, Chat scroll anchors, preview wrapping, Files expansion, composer attachments, and undo history. Only persisted Session-keyed Store values or a generation kept alive by another reference survive a view switch; the runtime does not clear those persisted values. -Every public `retain`, including the temporary references used by Session rename and fork-title assignment, starts the generation's shared initial history opening. These metadata operations await `reference.ready` before using the Session and therefore pay that history I/O for a cold generation. +Every public `retain`, including the temporary reference used by ordinary Session rename, starts the generation's shared initial history opening. Fork-title assignment instead sends rename directly and applies its returned projection without a Client reference or history I/O. A metadata-only fork operation therefore cannot create a temporary list row or switch the main view. The catalog, UI status, and view target have separate owners and can publish independently. Their consumers must not infer a lifecycle transition solely from notification order. The main view remains an ordinary reference owner, while its navigation and presentation rules stay in UI rather than the reference allocator. diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md index 7947a8a405..3af8932f7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md @@ -73,7 +73,7 @@ Controller 将来源计数与每个活跃代的引用共同管理。每个来源 每条已有 Session 列表记录公开只读 `retainedBy`,由来源键映射到正数引用计数。未被持有的记录使用空对象;零计数键不存在。获取失败与释放更新该投影,包括删除最后一个来源键。Host 元数据刷新时,来源计数仍是本地事实,Host 响应不能覆盖它。 -即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 为每个活跃代合成本地兜底记录,以便 Provider 与所有权使用方解析它;`ids` 仍表示 Host 列表成员关系与顺序。兜底记录不是 Host 元数据。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。 +即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 包含 Host 摘要、子会话目录投影行,以及已保留且具有明确直接父子地址的 subagent 兜底行。获取引用时同步发布缺失的 subagent 行,使 Provider 发现不依赖父目录到达;标题投影和引用来源计数继续更新该行。普通被保留 Session 不合成兜底行。`ids` 仍表示 Host 列表成员关系与顺序。未列出 generation 的使用方直接读取其 binding 与 `retainInfo`。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。 例如,`retainedBy = { mainView: 1, gateway: 2 }` 表示主视图与两个 Host 调用持有三份引用。释放主视图引用只移除 `mainView`;Gateway 调用继续持有该代。示例中的来源名是使用方键,不是 Controller 内封闭的枚举。 @@ -141,7 +141,7 @@ Conversation 的引用属于其视图所有者,不属于 Chat、Trajectory 或 ### 主区域导航与展示 -`uiWorkspace.openSession`、`openWorkspace`、`forkSession` 和 `startSession` 仍是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。 +`uiWorkspace.openSession`、`openWorkspace` 和 `startSession` 是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。侧边栏 `forkSession` 创建并重命名子会话,不 retain 子会话,也不改变选择。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。 现有 `uiWorkspace` 实现直接持有来源为 `mainView` 的主区域引用与目标。导航方法直接更新该所有者,不调用 Conversation 注册的接收者。`ui-session` 根据来源标记把该引用对应的 binding 注入根 Provider;主 Conversation 和关联右栏只继承 Provider,既不依赖 `uiWorkspace`,也看不到主引用。独立 Sidebar Conversation 以自己的 reference 建立嵌套 Provider,并覆盖本子树的 binding。主引用不是全局标准 prop、子树 Hook 或按 ID 查询的默认值。 @@ -160,7 +160,7 @@ DOM 焦点移动或全局面板隐藏仍被持有的视图时,来源元数据 Conversation 保留 `hero`、`settling`、`active` 组合与既有历史加载和 `openError` 处理。获取引用不增加外层 loading/error 阶段展示、额外隐藏 composer 的条件、Retry 按钮或替换 Sidebar 内容的恢复面板。已有错误处理方继续处理自己的错误;没有错误展示的调用点不增加展示。Promise 拒绝与正确释放引用不意味着额外增加 UI 处理方。 -Workspace 连接和 fork 保持既有导航检查与面板切换失效规则。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。 +Workspace 连接保持既有导航检查与面板切换失效规则。侧边栏 fork 不替代尚未完成的导航。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。 ### 预设与创建流程 @@ -221,6 +221,6 @@ Gateway 持有本地引用,直到 handler 使用与回复结算均结束。Con 最后一份 reference 释放后,未持久化的 binding 自有状态会被丢弃,包括已加载的历史页、Chat 滚动锚点、预览换行、Files 展开状态、composer 附件和 undo 历史。只有持久化的 Session-keyed Store 值或由另一份 reference 保活的 generation 能跨视图切换保留;runtime 不会清除这些持久值。 -每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括 Session 重命名和 fork 标题设置所用的临时引用。这些元数据操作在使用 Session 前等待 `reference.ready`,因此冷 generation 会承担这次历史 I/O。 +每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括普通 Session 重命名所用的临时引用。Fork 标题设置直接发送 rename 并应用返回的投影,不获取 Client 引用,也不读取历史。因此,仅修改元数据的 fork 操作不会创建列表临时行或切换主视图。 目录、UI 状态与视图目标由不同所有者管理,可以独立发布。使用方不能仅从通知顺序推断生命周期变化。主视图仍是普通引用所有者,其导航与展示规则留在 UI,不进入引用分配器。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.i18n.yaml new file mode 100644 index 0000000000..0a9151134f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.md +2026-09-17-session-row-menu-actions-slot.md: 034144b2b7c9d260f41565d3835687e52353c381 +2026-09-17-session-row-menu-actions-slot.zh.md: 6ad15d263fd41a0ad97a386506c8f581290d032f diff --git a/.agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.md b/.agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.md new file mode 100644 index 0000000000..034144b2b7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.md @@ -0,0 +1,35 @@ +# Agent Note: Session row actions as slot lists + +Status: implemented + +English | [中文](2026-09-17-session-row-menu-actions-slot.zh.md) + +## Problem + +The Session row's "..." menu and its hover buttons were closed lists owned by `ui-workspace`: the browser built the menu `items` by row state, rendered the pin and archive buttons itself, and dispatched every verb through callbacks it threaded down the tree, together with the toast and rename dialog those verbs needed. A client plugin could add a sidebar control of its own, but not an action beside the shipped ones without editing the owning package or copying the menu interaction. + +## Decision + +`ui-workspace` declares two root-scoped `list` slots under its `sidebar.workspaces` registration — `sidebar.workspaces.session.menu.item` for the menu rows and `sidebar.workspaces.session.row.action` for the hover buttons — and registers its own actions into them from `apply` exactly as a client plugin would: `pin` (menu 100, button 200), `rename` (200), `fork` (300), `archive` (menu 400, button 100). The lists are the menu and the button strip: entries render in ascending `order`, a plugin action lands wherever its order says, and reusing a shipped id at another `priority` shadows that action through the registry's ordinary cell rules. There is no separate "extension group": the owner draws no separator, and a group start is the entry's own `separatorBefore`. + +An entry receives only the row identity, `{ sessionId, displayTitle }`, and owns everything else. It reads the Host state it cares about through hooks its own face injects (`usePinned` and `useArchived`, Sets derived once per Workspace snapshot) and decides its own visibility from it: pin renders nothing on an archived row because the Host keeps the two sets exclusive; archive never consults pin. Its whole behavior lives in its registration's `inject` face, so a component only calls one callback: `pinSession` delegates to `uiWorkspace.pinSession`, which performs the Host pin and then fronts the Session in its accounts' saved orders with `pinSessionOrder`, reading the memberships current at completion from the object-layer snapshots (`pin-order.ts`), beside `archiveSession`, which the same service already owned; the archive action's callback adds the notice. The service writes view order through the viewing-store instance the apply creates and hands the browser's registration as its handle, the pattern `ui-layout` uses for its layout store. The registry-global pin and archive sets reach the entries as Sets through the faces' `hooks`, derived once per Workspace snapshot, so a row reads its own membership with one lookup. + +The surfaces an action raises outlive the row menu, so they are this package's `shell.overlay` entries: the rename dialog and the row-action notice. An action reaches its surface through the plugin's own apply-closure observable, injected as a callback on the action side (`requestSessionRename`, `notify`) and as a `hooks` source on the surface side (`useRenameRequest`, `useToast`). The browser raises the same rename request from a title double-click and the same notice when an archived row is clicked; the archived notice's second action switches the archived filter to "show" through the same store instance, since the browser's view-options menu is no longer in the loop. + +The shipped menu entries render `MenuItemButton`, the `ui-primitives` row component for menus whose rows are components: a plain row, markup and styling only. Any `role="menuitem"` button joins the menu's keyboard walk, submenu exclusivity, and focus return, because `Menu` decides all three from the DOM (the arrow walk queries the rows; submenu collapse and the post-selection focus return run on the list's own bubbles, for data rows and component rows alike) — a `cordis-client-runner` closure, which cannot import the primitive, renders such a button directly. Closing is the owner's state for both kinds of row: data rows through the owner's `onSelect`, component rows through the slot-level `useMenuOpenState` hook — the declaration carries `inject: { hooks: { menuOpenState } }`, `SessionNodeItem` passes its `[open, setOpen]` pair as the occurrence's `hookContext`, and the factory hands the pair back. That is the channel `sidebar.right.pane.tab` already uses for `useTabInfo`. + +## Alternatives considered + +**A React context between `Menu` and the row component.** Rejected: business components see zero React contexts ([client rules](../../../../packages/client/AGENTS.md)); the Menu-to-row relationship is parent-to-child through the slot system, so its control travels through the slot channels. + +**Owner-supplied row capabilities.** Passing `pinned`/`archived` and `pin`/`archive`/`rename`/`fork` callbacks to entries, through owner props or a slot-level hook, keeps the browser the owner of every verb and makes the shipped actions thin wrappers over it. Rejected: these are not capabilities of a menu; each action's behavior is that action's own, and an entry can read the Host state it needs directly. + +**A dedicated slot appended below the shipped rows.** Rejected: it makes the shipped rows a privileged group a plugin can only follow, and it needs a second rendering path in the primitive for what is one ordered list. + +**Entries that compute their own view-order writes.** A pin entry subscribing to the whole Session list and Workspace snapshot to assemble `pinSessionOrder`'s inputs re-renders every visible row on every list change and puts ordering detail into a menu row. Rejected: the injected callback owns the write and reads the snapshots once, at completion. + +**Declared shared stores for the rename request and the notice.** The request and the notice are one plugin's transient facts, so they travel as apply-closure observables through the entries' own inject faces. + +## Consequences + +Plugins add, reorder, or shadow Session row actions in the menu and in the hover strip without editing `ui-workspace`, and the shipped actions follow the same rules and the same registration path. `WorkspaceBrowser` no longer threads action callbacks, renders a toast, or hosts the rename dialog; `Menu` grew `children` and made `items`/`onSelect` optional; `shell.overlay` gained two `ui-workspace` entries. The archived notice applies the "show archived" filter directly instead of opening the view-options menu. diff --git a/.agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.zh.md b/.agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.zh.md new file mode 100644 index 0000000000..6ad15d263f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-session-row-menu-actions-slot.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Session 行 action 即 slot 列表 + +Status: implemented + +[English](2026-09-17-session-row-menu-actions-slot.md) | 中文 + +## 问题 + +Session 行的 "..." 菜单和行尾悬停按钮原本是 `ui-workspace` 持有的封闭列表:browser 按行状态拼菜单 `items`、自己渲染置顶和归档按钮、把每个动作的回调一路传下去,连这些动作需要的 toast 和重命名对话框也由它持有。客户端插件可以自己添加侧边栏控件,却无法在不修改 owner 包或复制菜单交互的情况下把 action 放到内置项旁边。 + +## 决策 + +`ui-workspace` 在其 `sidebar.workspaces` 注册项下声明两个 root-scoped `list` slot——菜单行用 `sidebar.workspaces.session.menu.item`,悬停按钮用 `sidebar.workspaces.session.row.action`——并在 `apply` 里以客户端插件的同一方式注册自己的 action:`pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100)。列表就是菜单和按钮条:条目按 `order` 升序渲染,插件 action 落在其 order 所指的位置,以另一个 `priority` 复用内置 id 则按注册表的普通 cell 规则遮蔽。没有单独的"扩展分组":owner 不画分隔线,分组起点是条目自己的 `separatorBefore`。 + +条目只接收行身份 `{ sessionId, displayTitle }`,其余一切自己负责。它用自己 face 注入的 hook 读自己关心的 Host 状态(`usePinned` 与 `useArchived`,每次 Workspace 快照只派生一次的 Set),并据此决定是否显示:Host 规定两个集合互斥,所以 pin 在归档行上不渲染;archive 从不参考 pin。整套行为放在注册项自己的 `inject` face 里,组件只调一个回调:`pinSession` 委托给 `uiWorkspace.pinSession`——先做 Host 置顶,成功后用 `pinSessionOrder` 把会话推到其记账保存顺序的最前,成员关系在完成时刻从对象层快照读取(`pin-order.ts`)——和这个服务原本就持有的 `archiveSession` 并列;归档 action 的回调在此之上加上提示。服务经 apply 自己创建、并作为 handle 交给 browser 注册项的那个 viewing store 实例写入视图顺序,与 `ui-layout` 处理 layout store 的方式相同。置顶与归档集合以 Set 形式经 face 的 `hooks` 到达条目,每次 Workspace 快照变化只重建一次,行读自己那一位只需一次查找。 + +action 发起的浮层必须活得比行菜单久,所以它们是本包注册在 `shell.overlay` 的条目:重命名对话框和行 action 提示。action 经本插件 apply 闭包里的观察源到达自己的浮层:action 一侧注入为回调(`requestSessionRename`、`notify`),浮层一侧注入为 `hooks` 源(`useRenameRequest`、`useToast`)。browser 在标题双击时发出同一个重命名请求,在点击归档行时发出同一种提示;归档提示的第二个动作经同一个 store 实例把归档筛选切到"显示",因为 browser 的视图选项菜单已不在链路上。 + +本包自己的菜单条目渲染 `MenuItemButton`——`ui-primitives` 为"行是组件"的菜单提供的行组件,只是一行普通 markup 与样式。任何 `role="menuitem"` 的按钮都能加入菜单的键盘走位、子菜单互斥与焦点归还,因为 `Menu` 从 DOM 上统一决定这三件事(方向键走位查询行元素;子菜单收起与选中后的焦点归还都在列表自己的冒泡上执行,数据行与组件行一视同仁)——无法 import 该 primitive 的 `cordis-client-runner` 闭包直接渲染这样一个按钮。两类行的关闭都是 owner 的状态:数据行通过 owner 的 `onSelect`,组件行通过 slot 级 `useMenuOpenState` hook——声明带 `inject: { hooks: { menuOpenState } }`,`SessionNodeItem` 把自己的 `[open, setOpen]` 作为该次渲染的 `hookContext` 传入,factory 把这一对原样交回。这正是 `sidebar.right.pane.tab` 用于 `useTabInfo` 的通道。 + +## 考虑过的替代方案 + +**在 `Menu` 与行组件之间放一个 React context。** 否决:业务组件不接触任何 React context([client 规则](../../../../packages/client/AGENTS.md));Menu 与行是经 slot 系统建立的父子关系,控制权就该走 slot 的通道。 + +**由 owner 提供行能力。** 把 `pinned`/`archived` 和 `pin`/`archive`/`rename`/`fork` 回调经 owner props 或 slot 级 hook 交给条目,会让 browser 继续拥有每个动作,内置 action 沦为薄封装。否决:这些不是菜单的能力,每个 action 的行为属于它自己,需要的 Host 状态条目可以直接读。 + +**在内置行下方追加一个专用 slot。** 否决:它把内置行变成插件只能跟在后面的特权分组,还要在 primitive 里为本来只是一个有序列表的东西开第二条渲染路径。 + +**让条目自己算置顶顺序写入。** pin 条目为了拼 `pinSessionOrder` 的输入订阅整个 Session 列表和 Workspace 快照,列表一变所有可见行都重渲染,排序细节还进了菜单行。否决:写入归注入的回调,在完成时刻读一次快照。 + +**用声明式共享 store 传重命名请求与提示。** 请求与提示是本插件自己的瞬时事实,所以经条目自己的 inject face 以 apply 闭包观察源传递。 + +## 后果 + +插件无需修改 `ui-workspace` 即可在菜单和悬停条里添加、重排或遮蔽 Session 行 action,内置 action 遵循相同规则、走相同注册路径。`WorkspaceBrowser` 不再传递 action 回调、渲染 toast 或承载重命名对话框;`Menu` 新增 `children`,`items`/`onSelect` 变为可选;`shell.overlay` 多了两个 `ui-workspace` 条目。归档提示直接应用"显示已归档"筛选,不再打开视图选项菜单。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml new file mode 100644 index 0000000000..ea121beb8c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md +2026-09-19-projection-cache-listing-identity-and-cached-rows.md: 61b82e5003ba4b634230941f5d3df65e65491e25 +2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md: ef49d90bc68ca501f7496adbac1d1aa8a5d706a7 diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md new file mode 100644 index 0000000000..61b82e5003 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md @@ -0,0 +1,166 @@ +# Agent Note: Projection cache read-only face matches the lifecycle identity; the client store separates cached and sequenced rows + +Status: implemented + +English | [中文](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md) + +## Problem + +After a Host process restart, every forked session (`SessionHeader.isSeeded === true`) shows no title in the sidebar session list, `@` reference completion shows only the session id, and list ordering degrades to creation time. Opening the session once restores all of it. On one development machine on 2026-09-19, 44 of 241 projcache records were seeded, all 44 carried a valid `title` row, and the list never read any of them. + +This is neither cache corruption nor a version mismatch. The projcache record's domain version is 7 and its `identity.formatVersion` is 4; those are the cache's own on-disk format generation and the Session log format generation the fold ran under, and both are current. The defect is on the read path. + +### Mechanism + +A cache record (record format and predecessor recovery: [Projection-cache predecessor recovery and Session-format binding](2026-09-02-projcache-cross-version-read-compat.md)) is bound to a lifecycle identity: `formatVersion + createdAt + cwd + isSeeded + inheritedEventCount`, matched by `identityMatches` on full equality. Since #3346, `inheritedEventCount` (the length of the event prefix a fork inherits, the cut below) no longer appears in the logical header: the header keeps only the `isSeeded` bit and the exact cut follows the body. Since Session format v2 (#3398) the physical header line no longer stores `seedLength` either; the reader derives the cut from the seq of the `session/end-seed {inherited: true}` marker in the body. + +A header-only read therefore cannot obtain the cut: the JSONL backend's `fromHeaderLine` hard-codes `inheritedEventCount: 0` for header-only reads, and `SessionPersistenceSnapshot` carries only the header, the revision, and an optional eventCount. All three header-only cache consumers grew the same guard: + +| Consumer | Guard | Fallback | Consequence | +|---|---|---|---| +| `packages/api/session-controller/src/list.ts` `projectionsFor` | `header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0)` | none | no title, no `sessionListMetadata`; blank falls back to `false`, ordering falls back to `createdAt` | +| `packages/context/session-reference/src/index.ts` `projectedLabels` | same | none | `@` completion labels by id; the title cannot be searched | +| `packages/subagent/subagent/src/list-children.ts` `resolveColdIdentity` | same | `observeSession` reads the body | correct result, one body read per seeded child | + +When the guard landed (#3346, 2026-09-01), `list.ts` still had `probeSmallCold`: on a cache miss it read the body when the log file was at most `DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024` bytes. Its purpose was blank-session detection; it covered only logs under 1KB and never applied to an ordinary fork. #3400 (2026-09-03) removed it and the list returned to metadata plus cache only. #4320 (2026-09-19) made forking a first-class feature (`packages/core/session/src/fork.ts`, fork at any seq); every fork is `isSeeded: true` with an exact cut, seeded sessions went from a handful to dozens, all of them cold after a restart, and the gap became visible at once. + +### The cache's two uses and where validation lives + +The read and write faces of `SessionProjectionCache` and their callers: + +| Face | What it does with the record | Writes back | Callers | +|---|---|---|---| +| `write(session)` | writes the checkpoint at three mandatory points plus a throttle | yes | the cache's own listeners | +| `hydratePrepared(session, events)` | uses `rows` as the fold starting point and applies events from `row.seq + 1`; the result lands in live cells | the next checkpoint | `session-query/src/observation.ts` | +| `coldSnapshot(meta, cut, events)` | fold starting point plus write-back | yes | no production caller | +| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`: each row passes `ver` and `stateSchema`, is `view`ed, and returned | no | `list.ts`, `session-reference`, `list-children` | +| `cachedPredecessorTitle(meta, cut)` | the same, `title` only, allowing an older `formatVersion` | no | `list.ts` | + +Identity validation lives entirely inside the cache (`recordFor` → `identityMatches`; `viewCheckpoint` checks `ver` and schema per row). `list.ts` validates nothing itself; it merely declines to call when it cannot supply a cut. + +spec.ts and the README describe the purpose of the identity check with one verb: preventing "seed state folded from an unrelated log", "cannot seed the caller". That guards the fold face. The read-only face has never seeded anything. + +### The client store cannot guarantee that connected data replaces hints + +Each session has exactly one `ProjectionValueStore` on the client (`manager.projectionStores`; its merge rules were recorded in [Session observations and projection-owned client state](2026-08-25-session-observations-and-projection-owned-client-state.md)). The block delivered by the list, the history first-page baseline on open, the control baseline, push frames, and rename results all write into that one object, and `useProjection` reads it. All writers are peers under one higher-seq-wins rule: `apply` drops a new value when `seq <= row.seq`, and `seed` clears omitted keys only when `row.seq <= cut`. A list hint whose seq is equal or higher survives untouched after the session is opened. The hint's seq comes from a disk record; after a crash-repair truncation it can be numerically higher than the connected cursor, which is exactly the case where the cache is wrong and the connection is right. Comparing seqs against a hint is the wrong tool. + +## Decision + +### Two read faces; the cut is compared only on the fold face + +| Face | Identity | Who can supply it | Methods | +|---|---|---|---| +| read-only face | lifecycle identity: `formatVersion + createdAt + cwd + isSeeded`, all from the header | any header-only caller | `cachedSnapshot(header, keys?)`, `cachedPredecessorTitle(header)` | +| fold face | lifecycle identity plus `inheritedEventCount` | callers holding the Session or its body | `hydratePrepared`, `write`, `coldSnapshot`, all through `recordFor` | + +The two read-only methods no longer take a cut parameter. `inheritedEventCount` is still written into every record and the fold face still compares it on full equality; the original design expectation, that records folded under different fork cuts never seed each other, is unchanged. + +In the block the read-only face returns, `asOfSeq` is the lowest watermark among the served rows, the stored record's own position. A header can vouch neither for the cut nor for the comparability of that watermark with the current log, so comparability is not expressed by this number: the Session list adds an independent field `kind` to each summary's `projections` block (`SessionProjectionHints`). A block a cold session's row viewed from projcache is `cached`; a block the Host's live registry computed for an attached session is `sequenced`. The two fields are independent facts: `kind` names the sequence space `asOfSeq` belongs to, and `asOfSeq` is the watermark within that space. + +### Why the read-only face may skip the cut + +- Within one `formatVersion`, a log's cut is determined by `(id, createdAt, cwd, isSeeded)`: it is fixed at fork creation, and only a cardinality-changing format migration can alter it, which necessarily bumps `formatVersion`. The cut is a derived fact of the lifecycle, not an independent coordinate. +- What the read-only face actually did until now: unseeded callers passed the constant 0 (true by construction) and seeded callers never called. The cut was never compared against any real fact on this face. Removing it changes the rejection set not at all: a record from another lifecycle (any of the four fields differing) is still rejected, unseeded results are identical, and seeded lifecycles go from unaddressable to addressable. +- The read-only face never seeds and never writes back; its output is a pure function of the stored record, the cut plays no part in it, and it flows nowhere. +- The one new exposure: a record whose four fields all match but whose cut differs. The system cannot produce it; only copying a session directory by hand and editing files can. Even then the list shows that record's values until the session is opened, and nothing enters any fold. + +### Consumers + +| Location | Change | +|---|---| +| `list.ts` `projectionsFor` | drop the `isSeeded` branch and `SessionLogOffset(0)`; every cold row reads `cachedSnapshot(header) ?? cachedPredecessorTitle(header)` | +| `session-reference` `projectedLabels` | same symptom, same change | +| `list-children.ts` `resolveColdIdentity` | follows the signature only; the `!header.isSeeded` guard and the body fallback stay, because it needs the cut to classify a seq as inherited or owned, which is fold semantics | + +### The client store separates cached and sequenced rows + +Rows in `ProjectionValueStore` come in two kinds, named for the property the rule depends on: whether the value carries a seq comparable within this connection. + +| Row | Meaning | Carries | +|---|---|---| +| `cached` | viewed from the persisted checkpoint without a Session; its seq is not comparable with this connection | `value` only | +| `sequenced` | computed by the Host for this Session within this connection; seqs share one space | `value` and `seq` | + +Rules: + +| Write | Against a `cached` row | Against a `sequenced` row | +|---|---|---| +| `applyCached(values)` | replaces | ignored | +| `seed(baseline)` | discards every `cached` row first, then writes the block; omitted keys clear under `seq <= cut` | higher-seq-wins | +| `apply(key, value, seq)` | replaces | higher-seq-wins | + +Seq comparison happens only within one Host connection: `handleConnected` clears the whole store, then the list writes cached rows again and opening a session seeds again. From cached to sequenced is an unconditional replacement without any seq comparison. + +Writer classification: + +| Entry | Row kind | +|---|---| +| the `projections` block of each summary in the `session.list` response (`manager.refreshList`) | by the block's `kind`: `cached` for a cold session, `sequenced` for a live one | +| the `projections` block of an `api-session/added` summary (`manager.handleSessionAdded`) | by the block's `kind`; the summary comes from a live session, so in practice `sequenced` | +| the history first page's `projections` (`projections.seed` in `session.ts`) | sequenced | +| the control baseline, live sessions only (`manager.replaceControlBaseline`) | sequenced | +| the `session.projections` result of `refreshProjections` (a body observation) | sequenced | +| push frames (the `projection` frame handled by `manager`) | sequenced | +| the `title` after a successful rename (`session.ts`) | sequenced | + +The `seed` and `apply` signatures are unchanged. The client routes by `kind`: a `sequenced` block goes through `apply` key by key, a `cached` block through `applyCached`, which does not read its `asOfSeq`. + +### What stays untouched + +- The Session format, the physical header line, `fromHeaderLine`, persistence, `SessionPersistenceSnapshot`. +- Hydration and the checkpoint write path; `recordFor` keeps full equality on all five fields. +- `coldSnapshot` stays; having no production caller is not a reason to delete it here. +- Old records (domain v4/v5, lacking `isSeeded` and `inheritedEventCount`) keep missing for seeded sessions until the session is opened and rewritten as a v7 record. +- The list still delivers every row with a wire view (`contextBreakdown`, `turnOutline`, and so on); narrowing that is out of scope. + +### Worst case, walked through + +1. The list serves a wrong record; the sidebar shows a wrong title, held in a cached row. +2. The session is opened; for the instant before the first-page baseline arrives the wrong value is still shown. Every pre-population has this window. +3. The first-page baseline `seed`s: every cached row is discarded unconditionally, then the block is written. Nothing of the wrong value survives, whatever its seq. +4. A later list refresh writes cached values that hit sequenced rows and are ignored; the wrong value does not come back. + +## Alternatives considered + +**Write the cut back into the physical header line.** Header-only reads would obtain the cut directly and the read-only face would keep its identity. The cost is another Session format generation right after V4 landed, and reversing #3346's decision that the exact cut does not pose as body-free metadata. Rejected: the header is not changed for this. + +**An index of each session's cut maintained by persistence or session-query.** A new durable index with its own consistency maintenance, serving one display read. Rejected. + +**Restore a bounded body probe.** The `probeSmallCold` idea #3400 removed, or a client-side asynchronous `refreshProjections` for visible seeded sessions. It breaks the list's zero-I/O principle, and the historical 1KB threshold shows it never covered an ordinary fork. Rejected. + +**Only the `asOfSeq: -1` sentinel, without store tiers.** One server-side change makes hints always lose under the seq rule. It rests on a convention: when a hint's seq equals or exceeds the baseline cut (crash-repair truncation), `apply` keeps the old row and the wrong value survives until the next frame. The user requires connected data to replace hints unconditionally, so the rule belongs in the store rather than in a seq convention. + +**Express cached through `asOfSeq: -1` and let the client route on the sentinel.** The read-only face always emits `-1` and the client treats every list block as cached. Rejected: one field would carry two meanings, and the second could only be inferred by convention; a live session's list block carries a real seq comparable within the connection, and treating every block as cached demoted those too. The PR review reproduced the regression: a delayed control baseline at a lower cut overwrote a newer list value. The independent field `kind` replaces the sentinel, and `asOfSeq` keeps each source's own watermark. + +**Remove the cut from the cache identity entirely.** The fold face needs it: `restore` continues applying from the cached row's state, and `schedule`, `subagentCatalog`, `permissions.seeded`, and owned/inherited classification encode the cut; an error would be written back and persisted. Rejected. + +**Naming the row kinds `hint` / `authoritative`.** Those words speak of trust, while the rule depends on seq comparability. Renamed `cached` / `sequenced` so the names state the property the rule uses. + +## Consequences + +Bought: + +- After a restart, forked sessions immediately show their title, `sessionListMetadata` (blank, lastPromptAt), and every other cached wire value in the sidebar, ordered by the last prompt time. +- `@` completion shows and searches forks by title. +- Connected data replaces list hints unconditionally, independent of seq ordering. +- The two levels of the cache identity have names: the lifecycle identity answers "is this the same log", the fold identity answers "may this continue a fold". + +Paid: + +- `cachedSnapshot` and `cachedPredecessorTitle` change signature; three callers change with them. +- `SessionProjectionHints` gains the required field `kind`; every producer of a list summary and every test fixture that builds one carries it. +- A hand-crafted record with the same four fields and a different cut is displayed in the list until the session is opened. +- Keys the baseline omits clear together with their hints: when a Host does not mount `schedule`, the schedule mark the list hinted disappears after the session opens. Under "connected data is the truth" this is the correct behavior. +- Old records still miss for seeded sessions until an open rewrites them. + +## Testing + +- `session-projection-cache/tests/cache.spec.ts`: a seeded cold header obtains every version-matching row through `cachedSnapshot(header)` at the row's watermark; an unseeded header is refused for the same id's seeded record; `coldSnapshot` continues from the row when the cut matches, refolds the whole log when it differs, and throws for an unseeded call with a nonzero cut; `cachedPredecessorTitle(header)` serves only `title` from a seeded record of an older `formatVersion`; rows with differing watermarks form one block whose `asOfSeq` is the lowest row. +- `session-projection-cache/tests/fixtures.spec.ts`: archived v3 to v6 records still expose only the predecessor title; lineage-less archives still miss for seeded callers. +- `api/session-controller/tests/session-cold.host.spec.ts`: a seeded cold summary carries `kind: 'cached'`, `title`, and `sessionListMetadata`, `updatedAt` takes `lastPromptAt`, the cache is queried, and the body is not read. +- `api/session-controller/tests/session-projections.host.spec.ts`: a real fork made through `sessions.fork` is checkpointed into projcache, the whole Context is disposed, and a Host restarted over the same storage root reads the `kind: 'cached'` `title` and `sessionListMetadata` through `session.list` from the header alone; `inspect` and `open` are never called. +- `api/session-controller/tests/projection-store.client.spec.ts`: `applyCached` fills empty keys only and never displaces a sequenced row; every sequenced write (including a frame at cursor `-1` and a baseline at a lower cut) replaces cached rows; a baseline discards all cached rows before clearing omitted keys; face subscribers are notified on cached fills and their discard. Manager path: a `cached` list block at any watermark is replaced by a control baseline at a lower cut, and a later list refresh cannot bring it back; a `sequenced` list block merges under higher-seq-wins, so a delayed baseline at a lower cut neither overwrites nor clears it while a higher-seq frame still advances it. +- `api/session-controller/tests/manager.client.spec.ts`: a `cached` `api-session/added` block is replaced by a control baseline at the same cursor; a `cached` list block does not displace an existing sequenced title. +- `api/session-controller/tests/inbox-projection.client.spec.ts`: unchanged; a live session's `sequenced` list block still outranks a delayed control baseline at a lower cut. +- `context/session-reference/tests/session-reference.spec.ts`: a seeded cold session is labeled and searchable by its cached title, a session without a cache record is still labeled by id, and neither reads a log. +- `subagent/subagent/tests/list-children.spec.ts`: unchanged; seeded children still go through body observation. diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md new file mode 100644 index 0000000000..ef49d90bc6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md @@ -0,0 +1,166 @@ +# Agent Note: Projection cache 只读面按 lifecycle 身份匹配,客户端 store 区分 cached 与 sequenced 行 + +Status: implemented + +[English](2026-09-19-projection-cache-listing-identity-and-cached-rows.md) | 中文 + +## Problem + +Host 进程重启后,所有 fork 出来的会话(`SessionHeader.isSeeded === true`)在侧边栏列表里没有 title,`@` 引用补全只显示会话 id,列表排序退化到创建时间。打开一次之后恢复。2026-09-19 的一台开发机上,241 条 projcache 记录里有 44 条 seeded,44 条的 `title` 行全部合法,列表一次都没读到。 + +这不是缓存损坏,也不是版本失配。projcache 记录的 domain version 是 7,`identity.formatVersion` 是 4,两者分别是缓存自身的磁盘格式代际和 fold 所依据的 Session 日志格式代际,都是当前值。问题在读取路径。 + +### 机制 + +缓存记录(记录格式与前代恢复见 [投影缓存前代恢复与 Session 格式绑定](2026-09-02-projcache-cross-version-read-compat.zh.md))绑定一份 lifecycle identity:`formatVersion + createdAt + cwd + isSeeded + inheritedEventCount`,`identityMatches` 做全等匹配。其中 `inheritedEventCount`(fork 继承的事件前缀长度,下称 cut)从 #3346 起不再出现在逻辑 header 里:header 只保留 `isSeeded` 这一位,精确 cut 跟随正文。Session 格式 v2 起(#3398),物理 header 行也不再存 `seedLength`,reader 从正文里 `session/end-seed {inherited: true}` 标记的 seq 推出 cut。 + +于是 header-only 的读取拿不到 cut:JSONL 后端 `fromHeaderLine` 对 header-only 读取硬编码 `inheritedEventCount: 0`,`SessionPersistenceSnapshot` 只有 header、revision 和可选的 eventCount。三个只读 header 的缓存消费者都因此加了同一个守卫: + +| 消费者 | 守卫 | 兜底 | 后果 | +|---|---|---|---| +| `packages/api/session-controller/src/list.ts` `projectionsFor` | `header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0)` | 无 | 无 title,无 `sessionListMetadata`,blank 判定退到 `false`,排序退到 `createdAt` | +| `packages/context/session-reference/src/index.ts` `projectedLabels` | 同 | 无 | `@` 补全按 id 显示,搜不到 title | +| `packages/subagent/subagent/src/list-children.ts` `resolveColdIdentity` | 同 | `observeSession` 读正文 | 结果正确,每个 seeded 子代一次正文读 | + +守卫存在时(#3346,2026-09-01),`list.ts` 还有 `probeSmallCold`:缓存 miss 且日志文件不超过 `DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024` 字节时读正文补全。它的目的是识别 blank 会话,只覆盖不到 1KB 的日志,对正常 fork 从未生效。#3400(2026-09-03)删掉它,列表回到纯元数据加缓存。#4320(2026-09-19)把 fork 变成一等功能(`packages/core/session/src/fork.ts`,任意 seq fork),每个 fork 都是 `isSeeded: true` 加精确 cut,seeded 会话数量从个位数变成几十条,重启后全部冷,问题集中显形。 + +### 缓存的两种用途与校验位置 + +`SessionProjectionCache` 的读写面和调用方: + +| 面 | 对记录做什么 | 写回 | 调用方 | +|---|---|---|---| +| `write(session)` | 三个强制点加节流写 checkpoint | 有 | 缓存内部监听 | +| `hydratePrepared(session, events)` | 把 `rows` 当 fold 起点,从 `row.seq + 1` 继续 apply,结果进 live cell | 后续 checkpoint 写回 | `session-query/src/observation.ts` | +| `coldSnapshot(meta, cut, events)` | fold 起点加回写 | 有 | 无生产调用方 | +| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`:每行过 `ver` 和 `stateSchema`,`view` 后返回 | 无 | `list.ts`、`session-reference`、`list-children` | +| `cachedPredecessorTitle(meta, cut)` | 同上,只看 `title`,允许更旧的 `formatVersion` | 无 | `list.ts` | + +身份校验全部在缓存内部(`recordFor` → `identityMatches`;`viewCheckpoint` 逐行校验 `ver` 与 schema)。`list.ts` 自己不校验,只是给不出 cut 时不调。 + +spec.ts 与 README 描述身份检查的目的时用的都是同一个动词:防止 "seed state folded from an unrelated log"、"cannot seed the caller"。它防的是 fold 面。只读面从未做过 seed。 + +### 客户端 store 无法保证建连数据覆盖提示 + +客户端每个会话只有一个 `ProjectionValueStore`(`manager.projectionStores`;合并规则原记录于 [Session observation 与 projection 所有的客户端状态](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)),列表下发的 block、打开会话的 history 首页基线、control 基线、推送 frame、rename 结果全部写进同一个对象,`useProjection` 读的也是它。所有写入者平等,只有一条 higher-seq-wins 规则:`apply` 在 `seq <= row.seq` 时丢弃新值,`seed` 清理没带的键时只清 `row.seq <= cut` 的行。一个 seq 相等或偏高的列表提示会在会话打开后原样留在格子里。列表提示的 seq 来自磁盘记录,crash-repair 截断日志后它可以数字上高于建连 cursor,而那正是缓存错、建连对的情形。对提示做 seq 比较是用错了工具。 + +## Decision + +### 缓存分成两个读面,cut 只在 fold 面比对 + +| 面 | 身份 | 谁能提供 | 方法 | +|---|---|---|---| +| 只读面 | lifecycle 身份:`formatVersion + createdAt + cwd + isSeeded`,全部来自 header | 任何 header-only 调用方 | `cachedSnapshot(header, keys?)`、`cachedPredecessorTitle(header)` | +| fold 面 | lifecycle 身份加 `inheritedEventCount` | 握有 Session 或正文的调用方 | `recordFor` 所服务的 `hydratePrepared`、`write`、`coldSnapshot` | + +只读面的两个方法不再接受 cut 参数。`inheritedEventCount` 继续写入记录,fold 面继续全等比对它;"不同 fork cut 的记录不能互相当种子"这条原始设计预期不变。 + +只读面返回的 block 里,`asOfSeq` 是所服务各行中最低的水位,也就是存储记录自己的位置。header 既作证不了 cut,也作证不了这个水位与当前日志的可比性,所以可比性不由这个数字表达:Session list 给每条摘要的 `projections` block(`SessionProjectionHints`)加独立字段 `kind`,冷会话从 projcache 看出来的 block 标 `cached`,活会话由 Host live registry 算出的 block 标 `sequenced`。两个字段各管各的:`kind` 说明 `asOfSeq` 属于哪个序列空间,`asOfSeq` 是那个空间里的水位。 + +### 只读面为什么可以不比 cut + +- 在同一个 `formatVersion` 下,一条日志的 cut 由 `(id, createdAt, cwd, isSeeded)` 唯一决定:fork 创建时写死,只有改变事件基数的格式迁移能改它,而那种迁移必然改 `formatVersion`。cut 是 lifecycle 的派生事实,不是独立坐标。 +- 只读面今天实际发生的是:unseeded 传常量 0(构造上恒真),seeded 直接不调。cut 在只读面上从未和任何真实事实比过。拿掉它,拒绝集合一个都不变:另一个 lifecycle 的记录(四字段任一不同)照样被拒;unseeded 结果相同;seeded 从不可寻址变成可寻址。 +- 只读面没有 seed、没有写回,输出是存储记录的纯函数,cut 不参与计算,也不流向任何地方。 +- 唯一多出来的暴露面:一份四字段全同、只有 cut 不同的记录。系统自身产生不出它,只有手工复制会话目录再改文件才行。就算出现,后果是列表显示它的值直到会话被打开,仍然不进任何 fold。 + +### 消费者 + +| 位置 | 改法 | +|---|---| +| `list.ts` `projectionsFor` | 删 `isSeeded` 分支和 `SessionLogOffset(0)`,冷会话统一 `cachedSnapshot(header) ?? cachedPredecessorTitle(header)` | +| `session-reference` `projectedLabels` | 同一症状,同一改法 | +| `list-children.ts` `resolveColdIdentity` | 只跟随签名,`!header.isSeeded` 守卫和正文兜底原样保留;它需要 cut 做 inherited/owned 分类,属于 fold 语义 | + +### 客户端 store 区分 cached 与 sequenced 行 + +`ProjectionValueStore` 的行分两类,按"这个值有没有本连接可比的 seq"命名: + +| 行 | 含义 | 携带 | +|---|---|---| +| `cached` | 从持久化 checkpoint 只读看出来的值,没有 Session 参与,seq 与本连接不可比 | 只有 `value` | +| `sequenced` | 本连接内由 Host 对该 Session 算出的值,seq 在同一空间可比 | `value` 加 `seq` | + +规则: + +| 写入 | 遇到 `cached` 行 | 遇到 `sequenced` 行 | +|---|---|---| +| `applyCached(values)` | 覆盖 | 忽略 | +| `seed(baseline)` | 整表丢弃 `cached` 行,再按 block 写入;block 没带的键按 `seq <= cut` 清理 | higher-seq-wins | +| `apply(key, value, seq)` | 覆盖 | higher-seq-wins | + +seq 比较只在同一条 Host 连接内发生:`handleConnected` 先整表 `clear()`,再由列表重新写 cached、打开会话重新 seed。从 cached 到 sequenced 是无条件替换,不看 seq。 + +写入口归类: + +| 入口 | 行类型 | +|---|---| +| `session.list` 响应里每条摘要的 `projections` block(`manager.refreshList`) | 按 block 的 `kind`:冷会话 `cached`,活会话 `sequenced` | +| `api-session/added` 摘要的 `projections` block(`manager.handleSessionAdded`) | 按 block 的 `kind`;该摘要来自活会话,实际为 `sequenced` | +| history 首页 `projections`(`session.ts` 的 `projections.seed`) | sequenced | +| control 基线,仅活会话(`manager.replaceControlBaseline`) | sequenced | +| `refreshProjections` 的 `session.projections` 结果(正文观察) | sequenced | +| 推送 frame(`manager` 处理 `projection` frame) | sequenced | +| rename 成功后的 `title`(`session.ts`) | sequenced | + +`seed` 与 `apply` 签名不变。客户端按 `kind` 分流:`sequenced` block 逐键 `apply`,`cached` block 走 `applyCached`,不读它的 `asOfSeq`。 + +### 不动的部分 + +- Session 格式、物理 header 行、`fromHeaderLine`、persistence、`SessionPersistenceSnapshot`。 +- hydration 与 checkpoint 写路径,`recordFor` 的五字段全等。 +- `coldSnapshot` 保留,不因没有生产调用方而删除。 +- 老记录(domain v4/v5,缺 `isSeeded` 与 `inheritedEventCount`)对 seeded 会话继续 miss,等会话打开后被 v7 记录改写。 +- 列表下发全部带 wire 视图的行(`contextBreakdown`、`turnOutline` 等)这一点不在本次范围。 + +### 最坏情形推演 + +1. 列表用了一份错误记录,侧边栏显示错 title,格子里是 cached 行。 +2. 点进会话,首页基线到达前一瞬仍是错值。这是任何预填充都有的窗口。 +3. 首页基线 `seed`:所有 cached 行先被无条件丢弃,再按 block 写入。错值零残留,与 seq 大小无关。 +4. 之后列表刷新,cached 写入遇到 sequenced 行直接忽略,错值不会被刷回来。 + +## Alternatives considered + +**把 cut 写回物理 header 行。** header-only 读取能直接拿到 cut,只读面无需改身份。代价是 Session 格式再升一版,V4 刚落地;并且要推翻 #3346"精确 cut 不伪装成 body-free metadata"的决定。否决:不为此改 header。 + +**由 persistence 或 session-query 维护每个会话 cut 的索引。** 新增一套 durable 索引及其一致性维护,只为服务一个展示读取。否决。 + +**恢复有界正文探测。** #3400 删掉的 `probeSmallCold` 思路,或由客户端对可见 seeded 会话异步 `refreshProjections`。违背列表零 I/O 原则,且历史阈值 1KB 说明它从未覆盖过正常 fork。否决。 + +**只用 `asOfSeq: -1` 哨兵,不给 store 分层。** 服务端一处改动即可让提示在 seq 规则下永远落败。但它靠约定成立:一旦提示 seq 与基线 cut 相等或偏高(crash-repair 截断),`apply` 保留旧行,错值存活到下一帧。用户要求建连数据无条件覆盖提示,规则要写进 store 而不是靠 seq 约定模拟。 + +**用 `asOfSeq: -1` 表达 cached,客户端按哨兵分流。** 只读面统一输出 `-1`,客户端把所有列表 block 当 cached。否决:一个字段同时承担两个含义,另一个含义只能靠约定推断;活会话的列表 block 带本连接可比的真实 seq,一律当 cached 就把它们也降级了,PR 评审复现了延迟到达、cut 更低的 control 基线覆盖更新列表值的回归。改为独立字段 `kind`,`asOfSeq` 保持各自来源的水位。 + +**把 cut 从缓存身份里彻底删掉。** fold 面需要它:`restore` 从缓存行状态继续 apply,`schedule`、`subagentCatalog`、`permissions.seeded` 与 owned/inherited 归属都编码了 cut,错误会被写回并持久化。否决。 + +**行类型命名 `hint` / `authoritative`。** 说的是可信度,而规则依据的是 seq 可比性。改为 `cached` / `sequenced`,名字直接说明规则用到的性质。 + +## Consequences + +买到的: + +- 重启后 fork 会话在侧边栏立即有 title、`sessionListMetadata`(blank、lastPromptAt)和其余带 wire 的缓存值,排序按最后一次提问时间。 +- `@` 补全对 fork 按 title 显示和搜索。 +- 建连后的数据无条件替换列表提示,不再依赖 seq 大小。 +- 缓存身份的两级含义有了名字:lifecycle 身份回答"是不是同一份日志",fold 身份回答"能不能当续算起点"。 + +付出的: + +- `cachedSnapshot` 与 `cachedPredecessorTitle` 签名变化,三个调用方同步修改。 +- `SessionProjectionHints` 新增必填字段 `kind`,所有产出列表摘要的地方和构造摘要的测试夹具都要带上。 +- 手工构造的同四字段、不同 cut 的记录会在列表上显示到会话被打开为止。 +- 基线没带的键连提示一起清掉:某个 Host 未挂载 `schedule` 时,打开会话后列表提示过的 schedule 标记消失。按"建连数据是真值"这是正确行为。 +- 老记录对 seeded 会话仍然 miss,直到打开重写。 + +## Testing + +- `session-projection-cache/tests/cache.spec.ts`:seeded 冷 header 通过 `cachedSnapshot(header)` 取到全部版本匹配的行,`asOfSeq` 为行水位;unseeded header 对同一 id 的 seeded 记录被拒;`coldSnapshot` 对 cut 一致的记录从行续算、对 cut 不一致的记录整段重新 fold,对 unseeded 且 cut 非 0 的调用抛错;`cachedPredecessorTitle(header)` 对 seeded 的更旧 `formatVersion` 记录只给 `title`;不同 watermark 的多行合成一个 block,`asOfSeq` 取最低行。 +- `session-projection-cache/tests/fixtures.spec.ts`:归档的 v3 到 v6 记录继续只透出 predecessor title;无 lineage 字段的归档对 seeded 调用方继续 miss。 +- `api/session-controller/tests/session-cold.host.spec.ts`:seeded 冷会话摘要携带 `kind: 'cached'`、`title` 与 `sessionListMetadata`,`updatedAt` 取 `lastPromptAt`,缓存确实被查询,正文没有被读。 +- `api/session-controller/tests/session-projections.host.spec.ts`:用 `sessions.fork` 造一个真实 fork 并写入 projcache,销毁整个 Context 后在同一存储根上重启 Host,只凭 header 通过 `session.list` 读到 `kind: 'cached'` 的 `title` 与 `sessionListMetadata`,`inspect` / `open` 一次都没被调用。 +- `api/session-controller/tests/projection-store.client.spec.ts`:`applyCached` 只填空位、不覆盖 sequenced 行;任何 sequenced 写入(包括 cursor `-1` 的 frame 和 cut 更低的基线)替换 cached 行;基线先丢弃全部 cached 行再清理没带的键;面订阅在 cached 填充与丢弃时都收到通知。manager 路径:`cached` 列表 block 的水位再高,也被 cut 更低的 control 基线替换,之后的列表刷新拿不回来;`sequenced` 列表 block 按 higher-seq-wins,cut 更低的延迟基线既不覆盖也不清掉它,更高 seq 的 frame 仍能推进它。 +- `api/session-controller/tests/manager.client.spec.ts`:`api-session/added` 的 `cached` block 被同 cursor 的 control 基线替换;`cached` 列表 block 不覆盖已有的 sequenced title。 +- `api/session-controller/tests/inbox-projection.client.spec.ts`:不变,活会话的 `sequenced` 列表 block 仍压过延迟到达、cut 更低的 control 基线。 +- `context/session-reference/tests/session-reference.spec.ts`:seeded 冷会话按缓存 title 标注并可按 title 搜到,没有缓存记录的会话仍按 id 标注,两者都不读日志。 +- `subagent/subagent/tests/list-children.spec.ts`:不变,seeded 子代仍走正文观察。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml index c18976d463..56aaada1b7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md -2026-08-17-durable-web-queue-recovery.md: c235cc47d5a68623249765753c2757948721ff6e -2026-08-17-durable-web-queue-recovery.zh.md: 2b224b5e1d63905e738dfda0772a3ea578c9aef3 +2026-08-17-durable-web-queue-recovery.md: 5fbfdf0e62c1034fbea138016cc2979489de474f +2026-08-17-durable-web-queue-recovery.zh.md: 2aaffd6bc571aab449d9429758fdf45bb4dd8d4c diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md index c235cc47d5..5fbfdf0e62 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md @@ -18,7 +18,7 @@ The registry folds committed splices before `Session.append()` returns; each Age The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block in each `session.follow` opening snapshot, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration. -A synchronous subscription to ready Host generations discards every retained projection value and watermark before refreshing queries and restarting the control stream, including cold Sessions absent from the process-local control baseline. The first control stream waits for generation readiness; a baseline cannot arrive before invalidation and then be erased by a later Cordis `connection/reset` notification. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Within a generation, all incoming baselines obey higher-seq-wins, so a delayed control baseline cannot remove or overwrite newer list or history values. +A synchronous subscription to ready Host generations discards every retained projection value and watermark before refreshing queries and restarting the control stream, including cold Sessions absent from the process-local control baseline. The first control stream waits for generation readiness; a baseline cannot arrive before invalidation and then be erased by a later Cordis `connection/reset` notification. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Within a generation, all Host-sequenced values obey higher-seq-wins, so a delayed control baseline cannot remove or overwrite newer history values or a live Session's list values; a cached list block viewed from the projection cache yields to the baseline instead ([listing identity and cached rows](../architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md)). The client Session binding retains `inbox` in its generic per-session projection store and does not copy it into `SessionSnapshot`. QueueDock reads `next-turn` directly. ChatView reads user-origin `next-step` messages directly and ignores injected context. Claiming removes a pending value through the durable splice; a later `user/message` is rendered through the ordinary conversation projection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md index 2b224b5e1d..2aaffd6bc5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md @@ -18,7 +18,7 @@ Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 We 通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在每次 `session.follow` 的起始快照中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。 -对已就绪 Host generation 的同步订阅会先丢弃所有保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括进程本地 control baseline 中没有列出的冷 Session。首次 control stream 会等待 generation 就绪;baseline 不会先于旧状态清理到达,再被较晚的 Cordis `connection/reset` 通知清除。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。同一 generation 内,所有收到的 baseline 都遵循较高 seq 优先,因此延迟到达的 control baseline 不能删除或覆盖较新的 list 或 history 值。 +对已就绪 Host generation 的同步订阅会先丢弃所有保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括进程本地 control baseline 中没有列出的冷 Session。首次 control stream 会等待 generation 就绪;baseline 不会先于旧状态清理到达,再被较晚的 Cordis `connection/reset` 通知清除。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。同一 generation 内,所有 Host 排序过的值都遵循较高 seq 优先,因此延迟到达的 control baseline 不能删除或覆盖较新的 history 值或活 Session 的 list 值;从 projection cache 看出来的 cached list block 则让位于该 baseline([只读面按 lifecycle 身份匹配与 cached 行](../architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。 客户端 Session binding 在通用逐会话投影存储中保留 `inbox`,不会把它复制进 `SessionSnapshot`。QueueDock 直接读取 `next-turn`。ChatView 直接读取用户来源的 `next-step` 消息,并忽略注入上下文。认领操作通过持久 splice 移除待处理值;后续 `user/message` 由普通会话投影渲染。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.i18n.yaml new file mode 100644 index 0000000000..a49e4b2351 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.md +2026-09-19-declared-session-attachment-carriers.md: 1619624800dc5e1ee8d6a4f4b64b84a6c80078c2 +2026-09-19-declared-session-attachment-carriers.zh.md: bd66b1c80469e2adbd7becc1b015d390978becc6 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.md b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.md new file mode 100644 index 0000000000..1619624800 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.md @@ -0,0 +1,31 @@ +# Agent Note: Declared Session attachment carriers + +Status: implemented + +English | [中文](2026-09-19-declared-session-attachment-carriers.zh.md) + +## Problem + +Session attachment authorization and ZIP export inferred attachment ownership from common payload field names. An unknown ignorable event could therefore authorize an image read or export file bytes. Compaction content under `summary` and `rawOutput` was outside that field-name scan. + +## Decision + +Both consumers select content fields by built-in event type. They inspect completed assistant blocks and direct content blocks, including flat V4 tool-role messages. Other content-block fields and unknown event payloads remain opaque, even when they contain objects resembling image or file references. + +The controller applies this rule before reading image bytes. The exporter applies it before reading image or file bytes and retains the complete logical log. Historical logs reach these readers after persistence converts them to current logical events. Title requests are excluded because V4 requires a single text block. Compaction uses its declared content fields: the basic producer currently emits text, while other valid compaction producers may supply attachments there. + +## Alternatives considered + +**Scan common keys on every event.** This lets a field name grant attachment access without a defined content meaning. It also misses valid content stored under other declared fields. + +**Interpret historical wrappers in attachment readers.** Historical conversion belongs to the adjacent format packages. Current attachment readers use flat V4 content and do not maintain a second interpretation of old blocks. + +## Consequences + +Plugins that store attachments only in custom events cannot use these built-in readers for those references. A plugin needs a supported content occurrence or its own reader. Adding a built-in content carrier requires updating both readers and their acceptance and refusal tests. Historical codec inventories remain version-owned; changing this policy must not change what a released decoder recognizes. + +The change affects attachment lookup and archive membership, not Session encoding or the model-visible message history. + +## Verification + +Controller and archive tests cover declared carriers, flat tool results, opaque plugin payloads, and misleading extra fields. Refusal tests assert that attachment storage is never read; archive tests also compare the exported log text. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.zh.md new file mode 100644 index 0000000000..bd66b1c804 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Session 声明的附件载体 + +Status: implemented + +[English](2026-09-19-declared-session-attachment-carriers.md) | 中文 + +## 问题 + +Session 附件授权与 ZIP 导出按常见载荷字段名推断附件归属。因此,未知可忽略事件也能授权图片读取或导出文件字节。压缩事件的 `summary` 与 `rawOutput` 内容不在这套字段名扫描范围内。 + +## 决策 + +两个消费者均按内置事件类型选择内容字段。它们检查已完成的 assistant 块与直接内容块,包括扁平的 V4 tool 角色消息。其他内容块字段与未知事件载荷保持不透明,即使其中含有类似图片或文件引用的对象。 + +控制器在读取图片字节前应用此规则。导出器在读取图片或文件字节前应用此规则,并保留完整逻辑日志。历史日志由持久化层转换成当前逻辑事件后交给这些读取器。标题请求不在附件载体之列,因为 V4 要求它只含一个文本块。压缩事件使用其声明的内容字段:基础生产者目前输出文本,其他合法压缩生产者可以在这些位置提供附件。 + +## 考虑过的替代方案 + +**扫描所有事件的常见键。** 这使字段名在没有明确内容语义时授予附件访问权,也会遗漏其他已声明字段中的有效内容。 + +**在附件读取器中解释历史包装块。** 历史转换由相邻格式包负责。当前附件读取器使用扁平的 V4 内容,不维护第二套旧块解释。 + +## 影响 + +仅在自定义事件中存储附件的插件不能通过这些内置读取器读取相关引用。插件需要使用受支持的内容位置,或提供自己的读取器。新增内置内容载体时,需要同步更新两个读取器及其接纳和拒绝测试。历史编解码器的清单仍归各版本所有;调整此策略不能改变已发布解码器识别的内容。 + +此变更影响附件查找与归档成员,不影响 Session 编码或模型可见消息历史。 + +## 验证 + +控制器和归档测试覆盖声明的载体、扁平工具结果、不透明插件载荷及误导性额外字段。拒绝测试断言不会读取附件存储;归档测试还比较导出的日志文本。 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index b0104c7e52..a4f2c8968a 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: d108d4f9833aca2a24c84d18d895e7ec63c0bfc2 -2026-08-05-agent-teams.zh.md: abf2ce3716e44effc8e34c0911cd9561d815aa08 +2026-08-05-agent-teams.md: 45c7e599e03bb26dc9224c74a6d3bc0ab8a74dcc +2026-08-05-agent-teams.zh.md: dbb2931bdc40da5ddf2c8091dd8c401029e0445f diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index d108d4f983..45c7e599e0 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -40,6 +40,8 @@ Fresh children have no inherited conversation. Fork children capture the Lead's Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds. +Unknown mailbox content stays as decoded JSON during event projection and checkpoint admission. Validation inspects its type without rebuilding its fields, because generic object parsing can omit a valid own `__proto__` key. Locally declared content variants undergo structural validation; opaque plugin fields do not gain Team semantics. Cache invalidation is necessary because parser correction alone cannot recover keys omitted from cached state. The [Team documentation](../../../../packages/experimental/agent-team/README.md) owns the restore behavior. + `send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale. Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index abf2ce3716..dbb2931bdc 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -40,6 +40,8 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前 Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。 +事件投影和 checkpoint 准入期间,未知 mailbox 内容保持为已解码 JSON。校验只检查其 type,不重建字段,因为通用对象解析可能省略有效的自有 `__proto__` 键。本地声明的内容变体接受结构校验;不透明的插件字段不获得 Team 语义。缓存失效是必要的,因为只修正解析器无法恢复缓存状态中省略的键。[Team 文档](../../../../packages/experimental/agent-team/README.zh.md)拥有恢复行为的说明。 + `send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index 961abe5984..d726d38ae1 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: caec59feb3a84140427bd3ae8b738274c3bc5c47 -2026-09-07-deepseek-messages-adapter.zh.md: 030c91cb9a5f566ce09892bb054b63b1d243f6bb +2026-09-07-deepseek-messages-adapter.md: b79c2526461843a9534d6a997029281a584e0bed +2026-09-07-deepseek-messages-adapter.zh.md: 49a967828e22803296ae3d4c8eef943b9ec8e3c5 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index caec59feb3..b79c252646 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -18,6 +18,8 @@ Assistant blocks remain the durable model-visible content. A versioned `ReplayEn Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. +JSON syntax failure after a successful HTTP response does not establish a transport failure. Files decoding uses the existing `INVALID_RESPONSE` code with operation context instead of relabeling the failure as `TRANSPORT` or adding a retry; the [provider README](../../../../packages/llm/llm-deepseek/README.md) owns the error fields. + System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint. Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 030c91cb9a..49a967828e 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -18,6 +18,8 @@ Status: implemented 两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。 +成功 HTTP 响应之后的 JSON 语法错误不足以证明传输故障。Files 解码复用已有的 `INVALID_RESPONSE` code 并补充操作上下文,不将其改标为 `TRANSPORT` 或添加重试;错误字段由 [provider README](../../../../packages/llm/llm-deepseek/README.zh.md) 维护。 + 系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。 Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index af7fc09ddb..cfbf30d80a 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: 5757aed3702dbcc7752e99912714bd1356a96206 -2026-09-09-web-sidebar-terminal.zh.md: 39870d1ba26cbc1d655b35a3d2dba61692d0fdb9 +2026-09-09-web-sidebar-terminal.md: 5ed920d744f847cc97b08645592f0be221f18b8d +2026-09-09-web-sidebar-terminal.zh.md: c3ceb0214c2f4db42b2fefb8b65619e198d9dc7e diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index 5757aed370..5ed920d744 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -16,7 +16,7 @@ The application theme supplies terminal default colors. The body reads resolved `api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider; [user-terminal permissions](../architecture/2026-09-16-user-terminal-permissions.md) govern their execution permissions independently of the Agent. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. -Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. +Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. Cleanup failures have no notification or manual retry action. Host terminals missing from the saved layout are not reopened automatically and have no UI recovery entry. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. [Sidebar layout persistence and provider recovery](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) owns browser reload: layout and tab identities are restored before the terminal provider reconnects its views. Listing still takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Only a new view may allocate a process; a recovered target that disappears reports an error. The Host remains authoritative for process state, titles and screen contents. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index 39870d1ba2..c3ceb0214c 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -16,7 +16,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 `api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider;[用户终端权限](../architecture/2026-09-16-user-terminal-permissions.zh.md)规定其独立于 Agent 的执行权限。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 -关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 +关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败没有通知或手动重试操作。保存布局之外的 Host 终端不会自动重新打开,也没有 UI 恢复入口。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 [侧栏布局持久化与 provider 恢复](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md)负责浏览器刷新:先恢复布局和标签身份,再由 terminal provider 重连视图。列表仍直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。只有新视图可以分配进程,恢复目标消失时显示错误。进程状态、标题和屏幕内容仍以 Host 为准。 diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml index 2d995ebcf4..b793133d0f 100644 --- a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md -2026-09-18-session-pin-and-sidebar-archive.md: eb0c82ba84b1ebc94b23bff4d613ec6de9565d71 -2026-09-18-session-pin-and-sidebar-archive.zh.md: d971c7b4ff2b6de6a0f49c41974b7654843b29ea +2026-09-18-session-pin-and-sidebar-archive.md: 262f7b76d6ac7eee21a646eedc93b7d936c86675 +2026-09-18-session-pin-and-sidebar-archive.zh.md: 86ddf4b58781c5974b93562396c3205cfdd0f9dc diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md index eb0c82ba84..262f7b76d6 100644 --- a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md @@ -18,7 +18,7 @@ The `dsh-workspace` registry stores registry-global pin and archive sets as Sess Each browser-local account has one complete Session sequence, including pinned and archived members. Workspace membership bounds each account; registry-global pin and archive sets never add a Session to an unrelated Workspace. Grouped, Ungrouped, and flat views use the same rules. -- Existing members retain their positions. Missing pinned members prepend in the pin array's order. Other missing members append by descending Session `updatedAt`, with missing archived members last. Supplementation never interleaves existing positions or duplicates an id. +- Existing members retain their positions. Missing pinned members prepend in the pin array's order. New ordinary forks enter immediately before their source, including nested forks, without inheriting its pin. Saved members keep their relative order, and visible pinned rows still lead ordinary forks. Other missing members append by descending Session `updatedAt`, with missing archived members last. Supplementation never duplicates an id. - Supplementation is an in-memory projection, not an automatic storage migration. A Session-order write records the complete supplemented sequence, including hidden archived rows. Filtering alone never rewrites Session positions. - A successful Pin action marks the Session pinned and moves its position to the front of its complete account sequence. Completion uses current saved positions and the latest Workspace membership, preserving reorders completed while the request was pending. Unpin removes the mark without restoring an earlier position. Both pinned and ordinary drags edit the same complete sequence; display indices never replace its hidden members. - The current blank New Session precedes both partitions; other pinned Sessions lead unpinned Sessions. Manual preserves the complete sequence's relative order within both partitions. Last updated sorts both partitions strictly by descending `updatedAt`, with Session id as the tie-break. Pin records carry only Session identities, not timestamps. diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md index d971c7b4ff..86ddf4b587 100644 --- a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md +++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md @@ -18,7 +18,7 @@ Status: implemented 每份浏览器本地排序记录包含一条完整会话序列,包括置顶和归档成员。每份序列受 Workspace 成员关系约束,全局置顶与归档集合不会把会话补进无关的 Workspace。分组、Ungrouped 和平铺视图使用同一套规则。 -- 已有成员保留位置。缺失的置顶成员按置顶数组中的顺序补到头部。其他缺失成员按会话 `updatedAt` 降序追加,缺失的归档成员放在最后。补齐不穿插已有位置,也不重复加入同一个 id。 +- 已有成员保留位置。缺失的置顶成员按置顶数组中的顺序补到头部。新增的普通 fork 紧邻来源之前插入,包括连续 fork,不继承来源的置顶。已保存成员保持相对顺序,可见置顶行仍排在普通 fork 之前。其他缺失成员按会话 `updatedAt` 降序追加,缺失的归档成员放在最后。补齐不重复加入同一个 id。 - 补齐是内存派生,不自动迁移存储。一次会话顺序写入保存补齐后的完整序列,包括隐藏的归档行。单纯切换筛选不重写会话位置。 - 置顶成功后,会话被标记为置顶,同时移到所属完整序列的首位。完成时使用当前保存的位置与最新 Workspace 成员关系,保留请求等待期间完成的重排序。取消置顶只移除标记,不恢复更早的位置。置顶行与普通行的拖拽都修改同一条完整序列,显示下标不会覆盖其中的隐藏成员。 - 当前空白新会话排在两个分区之前,其余会话再按置顶区、非置顶区排列。手动模式下,两区分别保持完整序列中的相对顺序。最近更新模式下,两区都严格按 `updatedAt` 降序排序,以会话 id 打破平局。置顶记录只包含会话身份,不包含时间戳。 diff --git a/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.i18n.yaml b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.i18n.yaml new file mode 100644 index 0000000000..be42dfe317 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.md +2026-09-19-evidence-led-simplification-surveys.md: c217f80239690ee2f7310ca8e97956ec06fb75c7 +2026-09-19-evidence-led-simplification-surveys.zh.md: 1b1a11731967c87ab54aa9eba82253f90bb2abde diff --git a/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.md b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.md new file mode 100644 index 0000000000..c217f80239 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.md @@ -0,0 +1,66 @@ +# Agent Note: Evidence-led simplification surveys + +Status: implemented + +English | [中文](2026-09-19-evidence-led-simplification-surveys.zh.md) + +## Problem + +Unused-symbol searches can miss working behavior whose implementation costs more than its required outcome, and can misclassify public extension APIs without fixed repository callers as dead. The previous simplification skill also combined discovery with lengthy archive procedures, omitted application and Python paths from its consumer examples, and suggested a proposal outline without the mandatory `Alternatives considered` section. + +## Decision + +The [simplification skill](../../../skills/dsh-find-simplifications/SKILL.md) traces a complete producer-to-outcome path, asks which distinctions change consumer actions, considers explicit reductions in supported behavior, and searches for maintained copies that an authoritative read can replace. It separates composition availability from deployment policy and counts removed configuration, lifecycle, tests, and documentation together with source code. + +Production use changes the required trade-off evidence; it does not automatically veto a proposal or authorize implementation. Dynamic plugin discovery, installed consumers, generated runtime assets, profiles, applications, and Python participate in consumer tracing. Protected adapters and persistence designs, released data, trust boundaries, independent invariant observations, synchronous publication, cancellation, and quiescent disposal remain constraints. + +The entry point separates decision criteria from an [optional historical reference](../../../skills/dsh-find-simplifications/references/historical-patterns.md) pairing mechanisms with counterexamples and residual obligations. Archive mechanics remain at their existing owner, and the proposal skeleton uses the canonical headings. + +## Historical evidence + +The author sampled 26 of the 100 English implemented or archived simplification notes: 11 implemented and 15 archived. All nine rejected simplification notes supplied counterexamples. The sample covers producerless variants, inert request knobs, explicit inputs, public projections, derived data, shared composition, lifecycle ownership, and dependency replacements; it is purposeful, not random or exhaustive. + +The frozen comparison inputs contained 2,222 whitespace-delimited words in the baseline entry, 1,372 in the revised entry, and 897 in its reference; the hashes below identify those measured artifacts. The reference teaches questions rather than current deletion instructions. An adapter-rejected knob differs from a working extension API; a simpler public status can retain internal residency distinctions; reading at feedback time changes the observation promise; replacing a dependency can lose deterministic clocks or necessary teardown. Archived records were read without following or repairing outbound links. No old note was edited, archived, or deleted. + +## Bounded survey evidence + +The source snapshot is master after #4618. Each arm uses fresh agents with the same inherited model and reasoning effort, identical owner domains, a time cap, and at most four submissions per domain. Other outputs remain hidden from discovery agents. The runtime domain covers `core`, `session`, `session-query`, `api`, `sdk`, and Python; execution covers `shell`, `subprocess`, `terminal`, `jobs`, `fs`, `mcp`, and `boot`. + +A third matched domain, covering `llm`, `compaction`, `context`, `skill`, and `extensions`, was added after the initial runtime results to broaden coverage. It is exploratory, uses a shorter equal cap, and remains separate from the initially declared two-domain comparison. The study stops after these three domains. + +The skill author derived the revision from historical notes while isolated from current candidates. The coordinator received accidental early baseline hints, but accepted the independent author's revision unchanged before revised runs. Candidate packets hid arm labels from a separate reviewer; the coordinator's integration was not blind. The frozen inputs had SHA-256 values `9830af2272a1a1c3832078f31a432c8abe0335a013bc6c248c072b388520e798` for the baseline entry, `3b101d16ae098855c9214ad7fed631a2f079b6078c83b49829ae87305f631a87` for the revised entry, and `9a6b674d1eeadb691481ea09838b211b8e94c94e5a8cd928726246b1c097b2b5` for its reference. + +After these runs, review replaced only the skill phrase “earns its place” with “is justified”; no discovery criterion changed and no new discovery run was performed. Each prompt requested exact source/search evidence, producer and consumer classification, note overlap, net reduction, the strongest counterargument, and acceptance requirements. Review applied those same criteria to every submission, distinguished local edits from durable decisions, and did not treat either public discoverability or a line-deletion count as a verdict by itself. + +| Version | Domain | Cap | Submitted | Durable as submitted | Durable after refinement | Local | Deferred | Elapsed | +|---|---|---|---:|---:|---:|---:|---:|---| +| Baseline | Runtime | 12m | 3 | 1 | 2 | 0 | 0 | 6m 30s | +| Baseline | Execution | 12m | 3 | 3 | 0 | 0 | 0 | 8m 40s | +| Revised | Runtime | 12m | 2 | 1 | 0 | 1 | 0 | 5m 05s | +| Revised | Execution | 12m | 3 | 2 | 0 | 1 | 0 | 8m 19s | +| Baseline | Model/context, exploratory | 10m | 2 | 2 | 0 | 0 | 0 | 6m 02s | +| Revised | Model/context, exploratory | 10m | 2 | 0 | 0 | 1 | 1 | 7m 51s | + +The baseline yields six durable proposals as submitted and two after refinement. The revised skill yields three durable proposals and three local cleanups, with one proposed behavior reduction deferred. Python RPC narrowing and filesystem-invariant omission overlap. The combined set contains nine distinct durable proposals; the revised version adds [configuration-only HMR](../../proposed/simplification/2026-09-19-config-only-hmr.md). This sample does not show higher durable-proposal yield from the revision. + +Review narrowed the [query API proposal](../../proposed/simplification/2026-09-19-trim-session-query-convenience-api.md) to retain `filterEvents`, whose literal substring behavior differs from FTS. The [prompt-event proposal](../../proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md) explicitly retires an active extension promise instead of claiming per-step assembly makes notifications redundant. Both refined baseline findings remain useful and are retained. + +The three revised-only local findings were a mirrored provider reference, copied filesystem results, and duplicate assembler ordering state. They were counted separately and were not written as active design proposals. + +Review deferred replacing automatic Session-reference sizing with a fixed byte default. The [current budget decision](../bug-fix/2026-09-05-session-reference-model-budget.md) fixes lost useful context on large models; best-effort spill retrieval does not replace inline context. No new workload evidence justified restoring the problematic default. The proposal's deletion inventory was accurate, but that alone did not overcome the current requirement. + +## Alternatives considered + +**Keep caller counts as the main filter.** This misses producer support, dynamic consumers, and useful explicit behavior reductions. More symbol matches do not establish which obligations can disappear. + +**Encode historical successes as a deletion checklist.** Architecture and consumers change. The reference uses mechanisms and counterexamples without making frozen records current authority. + +**Select the revision by proposal count.** The observed count does not support superiority. Its demonstrated benefit is complementary discovery and explicit classification; the stronger union includes findings from both versions. + +## Consequences + +The skill changes survey guidance, not runtime behavior. The nine proposals still require scoped implementation and relevant tests; their future implementation checks were not run. Three local opportunities remain evaluation findings, and the unsupported budget reversal is excluded from the proposal set. + +There is one discovery run per version/domain, no randomized order or repetition, unequal actual elapsed time within equal caps, and subjective review. Source reductions are estimates, not implemented diffs. These results establish neither statistical superiority, reproducible speedup, nor exhaustive coverage. + +The [dependency](2026-07-26-dependencies-over-hand-rolling.md), [prose](2026-08-09-concrete-prose-names-actors-and-recorded-facts.md), [archive](2026-07-26-frozen-agent-note-archive.md), and [performance-workflow](2026-09-06-evidence-driven-performance-skill.md) decisions retain their independent responsibilities. This note adds simplification-specific discovery and evaluation criteria without superseding those owners. diff --git a/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.zh.md b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.zh.md new file mode 100644 index 0000000000..1b1a117319 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-19-evidence-led-simplification-surveys.zh.md @@ -0,0 +1,66 @@ +# Agent Note: 以证据指导简化调研 + +Status: implemented + +[English](2026-09-19-evidence-led-simplification-surveys.md) | 中文 + +## 问题 + +仅搜索未使用的符号,可能漏掉实现成本超出必要结果的有效行为,也可能将没有固定仓库调用方的公共扩展 API 误判为死代码。原简化 skill(技能)还将发现候选与冗长的归档流程混在一起,消费方示例遗漏应用与 Python 路径,建议的提案结构也缺少必需的 `Alternatives considered` 章节。 + +## 决策 + +[简化 skill](../../../skills/dsh-find-simplifications/SKILL.md) 追踪从生产方到结果的完整路径,判断哪些区分会改变消费方动作,考虑明确收缩受支持行为,并寻找可以用权威读取替代的持续维护副本。它区分组合可用性与部署策略,并在源码之外合计删除的配置、生命周期、测试和文档成本。 + +生产使用会改变所需的取舍证据,但不会自动否决提案,也不构成实施授权。动态插件发现、已安装消费方、生成的运行时产物、profile、应用和 Python 都纳入消费方追踪。受保护的适配器与持久化设计、已发布数据、信任边界、独立 invariant 观察、同步发布、取消及达到静止状态的释放仍是约束。 + +入口将判断标准与[可选历史参考页](../../../skills/dsh-find-simplifications/references/historical-patterns.md) 分开,后者将机制与反例、残留义务配对。归档操作仍由原有归属处负责,提案结构使用规范标题。 + +## 历史证据 + +作者从 100 份英文已实现或归档简化记录中抽样 26 份:11 份已实现、15 份归档。全部九份已否决简化记录提供反例。样本涵盖无生产方变体、无效请求开关、显式输入、公共投影、派生数据、共享组合、生命周期所有权和依赖替换;这是有目的的抽样,并非随机或穷尽审查。 + +冻结的比较输入按空白分隔计数:基线入口为 2,222 词,新版入口为 1,372 词,参考页为 897 词;下方哈希标识这些已测量产物。参考页提供问题,而非当前删除指令。所有适配器都拒绝的开关不同于有效扩展 API;更简单的公共状态可以保留内部驻留差别;反馈时读取会改变观察承诺;依赖替换可能丢失确定性时钟或必要的释放行为。阅读归档记录时未追踪或修复其出站链接。没有编辑、归档或删除任何旧记录。 + +## 有界调研证据 + +源码快照固定为 #4618 之后的 master。每组使用继承相同模型和推理强度的全新 agent(智能体),获得相同归属领域、时间上限及每域最多四个提交项。发现候选的 agent 不能读取其他输出。运行时域覆盖 `core`、`session`、`session-query`、`api`、`sdk` 和 Python;执行域覆盖 `shell`、`subprocess`、`terminal`、`jobs`、`fs`、`mcp` 和 `boot`。 + +初始运行时结果产生后,增加第三个配对领域,覆盖 `llm`、`compaction`、`context`、`skill` 和 `extensions`,以扩大覆盖。该领域属于探索性补充,采用较短但双方相同的时间上限,与最初声明的两域比较分开。调研在这三个领域后结束。 + +skill 作者与当前候选隔离,根据历史记录撰写修订。协调者意外提前收到少量基线提示,但在新版运行前原样接受独立作者的修订。交给独立审查方的候选材料隐藏了分组标签;协调者整合并非盲审。冻结输入的 SHA-256 为:基线入口 `9830af2272a1a1c3832078f31a432c8abe0335a013bc6c248c072b388520e798`,新版入口 `3b101d16ae098855c9214ad7fed631a2f079b6078c83b49829ae87305f631a87`,新版参考页 `9a6b674d1eeadb691481ea09838b211b8e94c94e5a8cd928726246b1c097b2b5`。 + +这些运行结束后,评审仅将 skill 中的「earns its place」改为「is justified」;没有改变发现标准,也没有重新运行发现测试。每份提示都要求精确源码/搜索证据、生产方与消费方分类、记录重叠、净减少量、最强反对理由和验收要求。审查对每个提交项使用相同标准,区分局部编辑与长期决策,既不单凭公共可发现性,也不单凭删除行数作结论。 + +| 版本 | 领域 | 上限 | 提交数 | 原样可用的长期提案 | 调整后可用的长期提案 | 局部项 | 暂缓 | 耗时 | +|---|---|---|---:|---:|---:|---:|---:|---| +| 基线 | 运行时 | 12m | 3 | 1 | 2 | 0 | 0 | 6m 30s | +| 基线 | 执行 | 12m | 3 | 3 | 0 | 0 | 0 | 8m 40s | +| 新版 | 运行时 | 12m | 2 | 1 | 0 | 1 | 0 | 5m 05s | +| 新版 | 执行 | 12m | 3 | 2 | 0 | 1 | 0 | 8m 19s | +| 基线 | 模型/上下文,探索性 | 10m | 2 | 2 | 0 | 0 | 0 | 6m 02s | +| 新版 | 模型/上下文,探索性 | 10m | 2 | 0 | 0 | 1 | 1 | 7m 51s | + +基线产生六个原样可用的长期提案,另有两个调整后可用。新版 skill 产生三个长期提案和三个局部清理项,一项行为收缩被暂缓。Python RPC 收缩和文件系统 invariant 省略相互重叠。合并集合包含九个互不重复的长期提案;新版增加了[仅保留配置更新的 HMR](../../proposed/simplification/2026-09-19-config-only-hmr.zh.md)。该样本未显示新版的长期提案产出更高。 + +审查缩小了[查询 API 提案](../../proposed/simplification/2026-09-19-trim-session-query-convenience-api.zh.md),保留其字面子串语义不同于 FTS 的 `filterEvents`。[提示事件提案](../../proposed/simplification/2026-09-19-retire-prompt-registry-change-event.zh.md) 明确撤回当前扩展承诺,而非声称逐步骤组装让通知多余。这两项经调整的基线发现仍有价值,因此保留。 + +三个仅新版发现的局部项分别是镜像的提供方引用、复制的文件系统结果,以及重复的组装器顺序状态。它们单独计数,没有写成当前设计提案。 + +审查暂缓了将自动 Session 引用大小改为固定字节默认值的提案。[当前预算决策](../bug-fix/2026-09-05-session-reference-model-budget.zh.md) 修复了大模型丢失有用上下文的问题;尽力而为的 spill 检索不能替代内联上下文。没有新的工作负载证据支持恢复有问题的默认值。提案的删除清单准确,但仅此不足以推翻当前要求。 + +## 考虑过的替代方案 + +**继续以调用数量为主要筛选依据。** 这会遗漏生产方支持、动态消费方及有价值的明确行为收缩。更多符号匹配不能证明哪些义务可以删除。 + +**将历史成功案例编码为删除清单。** 架构与消费方会变化。参考页使用机制与反例,不将冻结记录视为当前权威。 + +**按提案数量选择修订。** 观察到的数量不支持优越性结论。已展示的收益是互补发现和明确分类;更有价值的合并集合包含两个版本的发现。 + +## 后果 + +skill 改变调研指导,不改变运行时行为。九份提案仍需限定范围的实现与相关测试;本次未运行其未来实施检查。三个局部机会保留为评估发现,缺少依据的预算逆转排除在提案集合之外。 + +每个版本/领域只有一次发现运行,没有随机顺序或重复试验,相同上限内的实际耗时不等,审查带有判断性。源码减少量是估算,并非实现 diff。这些结果不能证明统计优越性、可复现的提速或穷尽覆盖。 + +[依赖](2026-07-26-dependencies-over-hand-rolling.zh.md)、[措辞](2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md)、[归档](2026-07-26-frozen-agent-note-archive.zh.md)和[性能工作流](2026-09-06-evidence-driven-performance-skill.zh.md)决策保留各自独立职责。本记录增加简化专用的发现与评估标准,不取代这些归属处。 diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml index b85c97a4b8..cd6b1146aa 100644 --- a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.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/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md -2026-09-03-minimal-profiles-persistent-shell-only.md: d58210cb890e4264b02d591f213045b592ca6bfc -2026-09-03-minimal-profiles-persistent-shell-only.zh.md: 481ef479c2dc01ae503f621df976529aeb6bdbe0 +2026-09-03-minimal-profiles-persistent-shell-only.md: 2bc4ef7ada0767d96c0ac448e19e13e8e06a945c +2026-09-03-minimal-profiles-persistent-shell-only.zh.md: d54e5734ac04825e2d28f59d9104a01b5caf9fb7 diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md index d58210cb89..2bc4ef7ada 100644 --- a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md +++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md @@ -16,7 +16,7 @@ The shipped minimal compositions expose exactly one platform-selected persistent The standalone editor package remains available for explicit custom compositions. A trusted user-authored preset or higher profile patch must insert the editor into the Cordis tree with a filesystem provider in the same service scope; the shipped `minimal` and `sdk-minimal` defaults never insert it. The [Python SDK guide](../../../../docs/user/guide/python-sdk.md#opt-in-to-str_replace_editor) provides an executable patch example. -The shared [persistent Bash consumer](../../../../packages/shell/tool-bash-persistent/README.md#model-experience) uses the one-shot shell's command-status wording while retaining its persistent state. Settled commands append `[Command finished with exit code N]`, including success; timeout output includes `[Command timed out or OOM]` and the shell-reset notice. Trailing newlines are removed before the status trailer. Both minimal Bash descriptions state that network access depends on the task environment. Explicit compositions using this consumer share its output behavior; the persistent PowerShell description and output remain unchanged. +The shared [persistent Bash consumer](../../../../packages/shell/tool-bash-persistent/README.md#model-experience) uses the one-shot shell's command-status wording while retaining its persistent state. Settled commands append `[Command finished with exit code N]`, including success; timeout output includes `[Command timed out or OOM]` and the shell-reset notice. Trailing newlines are removed before the status trailer. Both minimal Bash descriptions state that network access depends on the task environment. Explicit compositions using this consumer share its output behavior. Persistent Bash and PowerShell settle cancelled commands only after shell cleanup; the existing ToolRuntime then publishes `ABORTED` instead of stringifying the user's cancellation object. Queued cancelled calls send no command, and execution or cleanup failures retain their errors. Exact composition tests assert the single tool and the absence of a preset-local filesystem service. The `sdk-minimal` bundle test and built config dump assert that its row and dependency allowlists contain neither `fs-local` nor `dsh-tool-str-replace-editor`. Web and packaged-Python model-visible snapshots pin the one-tool schema roster. SDK profile smoke tests execute the guide's editor patch and verify file creation and viewing. diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md index 481ef479c2..d54e5734ac 100644 --- a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md @@ -16,7 +16,7 @@ Status: implemented 独立 editor 包仍可用于显式自定义组合。受信任的用户自定义 preset 或更高优先级的 profile patch 必须将 editor 插入 Cordis tree,并在同一服务作用域内提供文件系统后端;随附的 `minimal` 与 `sdk-minimal` 默认组合不会插入它。[Python SDK 指南](../../../../docs/user/guide/python-sdk.zh.md#opt-in-to-str_replace_editor)提供可执行的 patch 示例。 -共享的[持久 Bash 消费方](../../../../packages/shell/tool-bash-persistent/README.zh.md#model-experience)保留跨调用状态,并采用单次 shell 的命令状态文案。完成的命令追加 `[Command finished with exit code N]`,成功时也包含该标记;超时输出包含 `[Command timed out or OOM]` 和 shell 重置说明。追加状态标记前会移除输出末尾的全部换行。两份极简 Bash 描述都说明网络访问取决于任务环境。使用该消费方的显式组合共享相同的输出行为;持久 PowerShell 的描述与输出保持不变。 +共享的[持久 Bash 消费方](../../../../packages/shell/tool-bash-persistent/README.zh.md#model-experience)保留跨调用状态,并采用单次 shell 的命令状态文案。完成的命令追加 `[Command finished with exit code N]`,成功时也包含该标记;超时输出包含 `[Command timed out or OOM]` 和 shell 重置说明。追加状态标记前会移除输出末尾的全部换行。两份极简 Bash 描述都说明网络访问取决于任务环境。使用该消费方的显式组合共享相同的输出行为。持久 Bash 和 PowerShell 在 shell 清理完成后才结束已取消命令,由现有 ToolRuntime 发布 `ABORTED`,不把用户取消对象转成字符串。排队期间被取消的调用不发送命令,执行或清理失败仍保留各自的错误。 精确组合测试会断言单工具清单以及 preset 内不存在文件系统服务。`sdk-minimal` bundle 测试与构建后配置转储会断言配置项和依赖 allowlist 都不含 `fs-local` 或 `dsh-tool-str-replace-editor`。Web 与打包 Python 的模型可见快照会固定单工具 schema 清单。SDK profile 冒烟测试会执行指南中的 editor patch,并验证文件创建和查看。 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 4376b51c83..42cefa39a0 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: d6fd6f74342fda3e1f3c686376f521bf82546e85 -2026-06-19-acp-snapshot-tests.zh.md: dfd09f9d9036763c5fd98393f078bf1772aa0beb +2026-06-19-acp-snapshot-tests.md: 3a21d09609d45b574d7f19c3704a44dde5a3d17c +2026-06-19-acp-snapshot-tests.zh.md: e9f3682289a05e4e54db2de3adcd3bee7042ef1b diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index d6fd6f7434..3a21d09609 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -57,7 +57,7 @@ A snapshot run asserts **two** normalized outputs, because the harness's externa The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits. -Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility; fixture projection omits body sequence/time envelopes without changing payload references. Record and refresh also store a generated workspace and its filesystem-resolved aliases as `{{cwd}}` in the replay fixture, so platform temp roots and random basenames do not affect recordings; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes. +Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility; fixture projection omits body sequence/time envelopes without changing payload references. The shared snapshot writer expands top-level `sourceEventSeqs` ranges before saving, so record and refresh produce canonical fixture bytes independently of replay normalization. Record and refresh also store a generated workspace and its filesystem-resolved aliases as `{{cwd}}` in the replay fixture, so platform temp roots and random basenames do not affect recordings; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes. ### Isolation: normalization now, sandbox later diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index dfd09f9d90..e9f3682289 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -57,7 +57,7 @@ Log 从持久 Assistant settlement 与显式标记的 compaction call 推导 chu 两个表面互补:stdout 覆盖精简的自动化协议格式,JSONL 覆盖协议格式有意省略的循环、工具和边界结构。 -规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值;fixture 投影会省略正文序号/时间 envelope,而不修改 payload 引用。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是符合协议格式的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。 +规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值;fixture 投影会省略正文序号/时间 envelope,而不修改 payload 引用。共享快照写入器在保存前展开顶层 `sourceEventSeqs` 区间,使录制与刷新直接产出规范 fixture 字节,无需依赖回放时的规范化。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是符合协议格式的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。 ### 隔离:当前靠归一化,后续可加沙箱 diff --git a/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.i18n.yaml new file mode 100644 index 0000000000..c959db81ed --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.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/proposed/simplification/2026-09-19-config-only-hmr.md +2026-09-19-config-only-hmr.md: a5f447a8a426e9f2831a1292cae1132acab99023 +2026-09-19-config-only-hmr.zh.md: 24651aa310994b3fec4d925a84fe5d88ae6a5851 diff --git a/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.md b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.md new file mode 100644 index 0000000000..a5f447a8a4 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.md @@ -0,0 +1,36 @@ +# Agent Note: Limit DSH HMR to live profile configuration + +Status: proposed + +English | [中文](2026-09-19-config-only-hmr.zh.md) + +## Problem + +[DSH HMR](../../../../packages/boot/hmr/src/index.ts) owns both serialized profile configuration refresh and running JavaScript module replacement. Module replacement adds Node-loader internals, dependency traversal, cache backup, old/new plugin fibers, rollback, module watches, and import-error formatting. The [base composition](../../../../packages/bundle/base/cordis.patch.yml) uses `root: []`; other shipped profiles disable or omit HMR. The default product requires configuration refresh, while source replacement serves an explicit custom-profile opt-in. + +That opt-in is real supported behavior: [profile tests](../../../../apps/cli/tests/profile-hmr.spec.ts), module tests, and a [built CLI test](../../../../apps/cli/tests/built-bin.e2e.ts) exercise it, and generated inspection APIs advertise its methods and events. This proposal trades developer continuity for a smaller maintained subsystem; it does not classify the code as unreachable. + +## Proposal + +Keep `watchConfig`, `runExclusive`, exact profile/home patch and manifest watches, application-readiness coordination, serialized refresh, and disposal drainage. Require a process restart for source-module, arbitrary Include, and framework-dependency changes; remove automatic framework-change calls to `loader.exit()`. Keep browser Client loading independent. + +Remove the module watcher and dispatch, dependency graph analysis, module-cache/fiber replacement and restoration, module-only state/options, `baseDir`, `getOuterStack`, `getLinked`, and `hmr/change`/`hmr/reload`. Remove the [import-error formatter](../../../../packages/boot/hmr/src/error.ts), module-only dependencies, and dedicated module/error tests. Keep Chokidar and configuration watcher options actually used by [exact watches](../../../../packages/boot/hmr/src/watch-config.ts). Roughly 400 identified source lines and 372 dedicated test lines belong to the removable module paths; retained configuration code and mixed tests must be counted separately in implementation. + +Update the [profile-management decision](../../implemented/architecture/2026-09-14-current-profile-plugin-management.md) and [single-launcher decision](../../implemented/architecture/2026-08-22-single-dsh-application-launcher.md) only where they promise module replacement or its coordination. Their configuration, installation, and launcher responsibilities remain. The [non-transactional Loader decision](../../implemented/simplification/2026-09-09-nontransactional-loader.md) motivates avoiding duplicated rollback but does not already authorize withdrawing source HMR. + +## Alternatives considered + +**Keep opt-in source replacement.** Plugin authors can edit code without restarting an active Session, and failed replacements restore old plugins. That capability is useful; this proposal deliberately gives it up to remove loader-version coupling and replacement-state ownership from DSH. + +**Remove only rollback or delegate replacement elsewhere.** Partial removal can leave cached modules and live fibers from different generations. A wrapper around another HMR implementation retains the public behavior and coordination burden. Neither establishes the complete deletion proposed here. + +## Acceptance criteria + +- Live profile/home patch and bundle-list changes, Plugin Manager serialization, readiness, self-removal, disposal drainage, and recovery after a later valid edit retain coverage. +- Source and arbitrary Include changes take effect on restart. Removed source options receive an explicit migration/configuration error; update shipped `root: []` rows rather than silently ignoring obsolete options. +- Remove module-only APIs, events, dependencies, generated declarations, and tests. Preserve the remaining public queue/watch APIs and package installation/restart semantics. +- Run focused HMR configuration/watch/coordination and Plugin Manager tests, app-boot reload coverage, and a real built CLI profile-refresh scenario. Replace source-replacement expectations with the agreed restart behavior and update required assembled snapshots and documentation. + +## Risks + +Custom profiles and installed plugins lose live server-code replacement and its failure restoration. Restarting can interrupt long-lived development work; acceptance therefore requires agreement to this product trade-off. Removing shared configuration coordination or recreating source replacement in another package would defeat the proposal. diff --git a/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.zh.md b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.zh.md new file mode 100644 index 0000000000..24651aa310 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-config-only-hmr.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 将 DSH HMR 限定为实时 profile 配置更新 + +Status: proposed + +[English](2026-09-19-config-only-hmr.md) | 中文 + +## 问题 + +[DSH HMR](../../../../packages/boot/hmr/src/index.ts) 同时拥有串行 profile 配置刷新和运行中的 JavaScript 模块替换。模块替换增加了 Node loader 内部接口、依赖遍历、缓存备份、新旧插件 fiber、回滚、模块监听与导入错误格式化。[基础组合](../../../../packages/bundle/base/cordis.patch.yml) 使用 `root: []`;其他随产品发布的 profile 禁用或省略 HMR(热模块替换)。默认产品需要配置刷新,而源码替换服务于自定义 profile 的显式启用。 + +该选项是真实受支持的行为:[profile 测试](../../../../apps/cli/tests/profile-hmr.spec.ts)、模块测试和[构建后 CLI 测试](../../../../apps/cli/tests/built-bin.e2e.ts) 都覆盖它,生成的检查 API 也展示其方法和事件。本提案以开发连续性换取更小的维护子系统,并不将代码判定为不可达。 + +## 提案 + +保留 `watchConfig`、`runExclusive`、精确的 profile/home 补丁与 manifest(元数据清单)监听、应用就绪协调、串行刷新和释放排空。源码模块、任意 Include 和框架依赖的修改要求重启进程;删除框架变化时对 `loader.exit()` 的自动调用。浏览器 Client 加载保持独立。 + +删除模块监听与分发、依赖图分析、模块缓存/fiber 替换与恢复、仅供模块使用的状态/选项、`baseDir`、`getOuterStack`、`getLinked` 及 `hmr/change`/`hmr/reload`。删除[导入错误格式化器](../../../../packages/boot/hmr/src/error.ts)、仅供模块使用的依赖和模块/错误专用测试。保留 Chokidar 及[精确监听](../../../../packages/boot/hmr/src/watch-config.ts) 实际使用的配置监听选项。已定位约 400 行源码和 372 行专用测试属于可删除模块路径;实现时必须单独计算保留的配置代码和混合测试。 + +仅修改[profile 管理决策](../../implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md) 与[单一启动器决策](../../implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md) 中承诺模块替换及其协调的部分。其配置、安装与启动器职责保留。[非事务式 Loader 决策](../../implemented/simplification/2026-09-09-nontransactional-loader.zh.md) 为避免重复回滚提供理由,但尚未授权撤回源码 HMR。 + +## 考虑过的替代方案 + +**保留可选源码替换。** 插件作者可以在不重启活动 Session 的情况下编辑代码,失败替换会恢复旧插件。这项能力有价值;本提案有意放弃它,以移除 DSH 对 loader 版本的耦合及替换状态所有权。 + +**仅删除回滚或将替换委托到别处。** 部分删除可能让缓存模块和活动 fiber 属于不同代。包装另一个 HMR 实现仍保留公共行为及协调负担。这两种方式都不能实现此处提出的完整删除。 + +## 验收标准 + +- 实时 profile/home 补丁与组合包列表变化、Plugin Manager 串行化、就绪、自身移除、释放排空及后续有效编辑后的恢复仍有覆盖。 +- 源码和任意 Include 修改在重启后生效。删除的源码选项给出明确迁移/配置错误;更新随产品发布的 `root: []` 项,不静默忽略过时选项。 +- 删除仅供模块使用的 API、事件、依赖、生成声明与测试。保留剩余公共队列/监听 API,以及包安装/重启语义。 +- 运行聚焦的 HMR 配置/监听/协调及 Plugin Manager 测试、app-boot 重载覆盖与真实构建后 CLI(命令行界面)profile 刷新场景。将源码替换预期改为已同意的重启行为,并更新所需的组装快照与文档。 + +## 风险 + +自定义 profile 和已安装插件会失去实时服务端代码替换及其失败恢复。重启可能中断长时间开发工作,因此接受该提案需要同意此产品取舍。删除共享配置协调,或在另一个包重建源码替换,都会违背提案目的。 diff --git a/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.i18n.yaml new file mode 100644 index 0000000000..e582498d41 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.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/proposed/simplification/2026-09-19-omit-fs-payload-invariant.md +2026-09-19-omit-fs-payload-invariant.md: e91fa1784aa99869e3d457f7fc25a330fbc43088 +2026-09-19-omit-fs-payload-invariant.zh.md: ba81baed5f7d7051ef22218d9d59180ac87c8a21 diff --git a/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.md b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.md new file mode 100644 index 0000000000..e91fa1784a --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.md @@ -0,0 +1,34 @@ +# Agent Note: Omit the filesystem payload-only invariant companion + +Status: proposed + +English | [中文](2026-09-19-omit-fs-payload-invariant.zh.md) + +## Problem + +The [filesystem invariant](../../../../packages/fs/fs/src/invariant.ts) checks non-empty target strings, a non-empty present version, and an observation discriminant. It reads one dispatch payload, retains no history, and compares no provider, policy, filesystem, or independently mutable state. Its [tests](../../../../packages/fs/fs/tests/invariant.spec.ts) construct malformed payloads directly. Publishing this check costs a companion module, export/build wiring, invariant dependencies, compiler references, and dedicated tests. + +Current guidance needs an explicit decision: the [independent-observation rule](../../implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.md) requires observations that can independently diverge, while the [runtime-contract examples](../../implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md) specifically endorse filesystem target/version validity. The later rule retains the earlier note's semantic authority, so chronology alone does not resolve this conflict. + +## Proposal + +Apply the independent-observation criterion to this concrete companion and remove it. The event producers are independent plugins, but checking the format of each single payload does not compare their observations. Keep typed filesystem events and the real [observation-policy consumer](../../../../packages/fs/fs-observation-policy/src/index.ts), along with parser, wire, read, edit, and write validation at their existing owners. + +Remove the 48-line companion, its 55-line dedicated test, `./invariant` export/publication, invariant-only dependencies and compiler references. Add the package-specific omission reason to both fs READMEs. Amend the earlier representative-check row when implementing this decision, retaining its other semantic examples and the shared invariant registration machinery. This proposal leaves those current owners unchanged until acceptance. + +## Alternatives considered + +**Keep payload validation as an explicit policy exception.** It can detect malformed third-party emissions early, but this would deliberately broaden the stated independent-observation criterion. The proposal prefers input validation at its owner and reserves companions for relational checks. + +**Add a second state source to justify the companion.** Rejected without a concrete missing invariant. Duplicating policy state or exposing new API solely to retain this diagnostic increases the maintenance burden. + +## Acceptance criteria + +- Resolve the named policy conflict in the active owning notes; do not claim this is already an uncontested mechanical omission. +- No companion source, test, export, artifact entry, invariant-only dependency, or compiler reference remains; package topology checks accept the explained omission. +- Real file-tool and observation-policy tests retain stale-version, absent-target, alias, actor-routing, and world-state assertions. The events and consumers remain. +- Run focused fs and observation-policy tests, relevant file-tool integration checks, source/built invariant-topology checks, build/hygiene, doc-sync, and lint. Do not replace the module with equivalent generic payload-validation machinery. + +## Risks + +Malformed trusted-plugin emissions lose this opt-in early diagnostic. The check's removal does not justify weakening validation of external data, nor removing other companions that compare lifecycle, durable history, or independently assembled values. If payload-format diagnostics are intentionally part of the companion policy, retain this module and clarify that exception instead. diff --git a/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.zh.md b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.zh.md new file mode 100644 index 0000000000..ba81baed5f --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-omit-fs-payload-invariant.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 省略仅校验载荷的文件系统 invariant 配套模块 + +Status: proposed + +[English](2026-09-19-omit-fs-payload-invariant.md) | 中文 + +## 问题 + +[文件系统 invariant](../../../../packages/fs/fs/src/invariant.ts) 检查目标字符串非空、present 版本非空及观察值的判别标签。它只读取一次分发的载荷,不保留历史,也不与提供方、策略、文件系统或独立可变状态比较。其[测试](../../../../packages/fs/fs/tests/invariant.spec.ts) 直接构造格式错误的载荷。发布该检查需要配套模块、导出/构建配置、invariant 依赖、编译器引用和专用测试。 + +当前指导需要显式决策:[独立观察规则](../../implemented/simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md) 要求能够独立分歧的观察值,而[运行时约定示例](../../implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md) 明确认可文件系统目标/版本有效性检查。后者的语义权威仍被较新的规则保留,因此不能仅凭时间先后解决冲突。 + +## 提案 + +将独立观察标准应用于这个具体配套模块,并删除它。事件生产方来自独立插件,但逐一检查单个载荷的格式,并没有比较它们的观察值。保留类型化文件系统事件和实际的[观察策略消费方](../../../../packages/fs/fs-observation-policy/src/index.ts),以及现有归属处的解析器、wire、读取、编辑和写入校验。 + +删除 48 行配套模块、55 行专用测试、`./invariant` 导出/发布项、仅供 invariant 使用的依赖和编译器引用。在 fs 的两个 README 中补充包特有的省略理由。实现本决策时修改较早的代表性检查行,保留其他语义示例及共享 invariant 注册机制。本提案获接受前不修改这些当前归属文档。 + +## 考虑过的替代方案 + +**将载荷校验保留为显式策略例外。** 它能提前发现第三方格式错误的事件,但这会有意扩大已声明的独立观察标准。提案倾向于在输入归属处校验,并将配套模块留给关系检查。 + +**添加第二个状态源以证明配套模块有必要。** 在没有具体缺失 invariant 的情况下否决。仅为保留此诊断而复制策略状态或暴露新 API,会增加维护负担。 + +## 验收标准 + +- 在当前归属 Agent Notes 中解决已指出的策略冲突;不将其宣称为已有共识的机械省略。 +- 不再保留配套源码、测试、导出、产物条目、仅供 invariant 使用的依赖或编译器引用;包拓扑检查接受有说明的省略。 +- 真实文件工具和观察策略测试保留过期版本、目标缺失、别名、执行方路由及外部状态断言。事件及其消费方保留。 +- 运行聚焦的 fs 与观察策略测试、相关文件工具集成检查、源码/构建产物 invariant 拓扑检查、build/hygiene、doc-sync(文档同步门禁)和 lint。不用等价的通用载荷校验机制替换该模块。 + +## 风险 + +来自受信任插件的格式错误事件会失去这项可选的早期诊断。删除该检查不构成削弱外部数据校验的理由,也不意味着可以删除比较生命周期、持久化历史或独立组装值的其他配套模块。如果载荷格式诊断本来就是配套模块策略的一部分,应保留模块并澄清该例外。 diff --git a/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.i18n.yaml new file mode 100644 index 0000000000..46510688ef --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.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/proposed/simplification/2026-09-19-provider-only-instruction-reads.md +2026-09-19-provider-only-instruction-reads.md: 26d004c136cff8deb155007cf8d7743bc8bba95a +2026-09-19-provider-only-instruction-reads.zh.md: 82f3451f96578f1ee596bf9291a1a3c935d5f698 diff --git a/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.md b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.md new file mode 100644 index 0000000000..26d004c136 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.md @@ -0,0 +1,37 @@ +# Agent Note: Read instruction files only through a filesystem provider + +Status: proposed + +English | [中文](2026-09-19-provider-only-instruction-reads.zh.md) + +## Problem + +[Instruction discovery](../../../../packages/context/agent-instructions/src/files.ts) maintains provider and direct-Node paths for metadata probes, root-marker discovery, and bounded reads. `nodeStatFile`, `nodeTextChunks`, and optional provider/target fields support the latter. The exported `discoverBaselineInstructionFiles` wrapper selects it; `loadBaselineInstructions` permits it when callers omit a provider. + +Every repository production caller supplies a provider. The [plugin](../../../../packages/context/agent-instructions/src/index.ts) reads `ctx.get('fs')` and returns when absent; [reconciliation](../../../../packages/context/agent-instructions/src/state.ts) requires `FileSystem`. Direct-Node consumers are tests. Maintaining both paths therefore adds separate error and cancellation coverage without serving a shipped product path. + +## Proposal + +Require `FileSystem` throughout discovery and loading, including both exported wrappers and `findProjectRoot`. Remove `nodeStatFile`, `nodeTextChunks`, Node-only missing-path classification, the Node branch of `existsAsMarker`, and the `statFile` selector. Provider discovery supplies required targets; bounded reads consume those targets through `streamText`. + +Keep the plugin's missing-provider no-op and the pure `renderAgentInstructions` API. Preserve candidate precedence, source and rendered-output byte budgets, cancellation, symlink following, and error semantics: unavailable candidates cannot imply removal, while root-marker failures remain errors. The separate `skill-filesystem` owner retains its existing providerless behavior. + +Migrate direct helper tests to explicitly owned local or controlled providers. The [test suite](../../../../packages/context/agent-instructions/tests/agent-instructions.spec.ts) already mounts both. Preserve unique assertions and consolidate duplicate Node-mocking cases into provider coverage. Expected deletion is roughly 50–65 production lines before signature/documentation updates, plus redundant test machinery; measure the actual reduction during implementation. + +The [symlink decision](../../implemented/feature/2026-07-21-follow-instruction-symlinks.md) remains active because its accepted behavior survives. At implementation, update its references to the two implementations and the [package README](../../../../packages/context/agent-instructions/README.md), including bilingual counterparts. The [archived workspace-context note](../../archived/feature/2026-06-24-workspace-context.md) remains frozen; no existing note changes accompany this proposal. + +## Alternatives considered + +**Keep providerless convenience exports.** They let external callers load instructions without constructing a provider, but require a second I/O implementation that production bypasses. This proposal deliberately gives up that convenience; repository searches do not establish external non-use. + +**Construct a local provider implicitly.** This preserves the omitted argument while hiding filesystem selection and lifecycle ownership. Callers should supply the implementation they intend to use. + +## Acceptance criteria + +- This owner's discovery/read helpers require a provider and contain no direct-Node I/O fallback; providerless plugin composition still loads nothing. +- Focused tests preserve symlink, absence/unavailability, marker-error, budget, cancellation, resume, nested-touch, and compaction-restoration behavior. +- The workspace-context resume process expectation and `ptc-workspace-context` scenario retain their text and event order. Typecheck, affected builds, and documentation checks pass. + +## Risks + +External callers omitting the provider face an intentional pre-stable API change. Identify such requirements before implementation. Keep filesystem extension paths intact, and do not replace useful provider tests with a new convenience wrapper that recreates the removed fallback. diff --git a/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.zh.md b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.zh.md new file mode 100644 index 0000000000..82f3451f96 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-provider-only-instruction-reads.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 仅通过文件系统提供方读取指令文件 + +Status: proposed + +[English](2026-09-19-provider-only-instruction-reads.md) | 中文 + +## 问题 + +[指令发现](../../../../packages/context/agent-instructions/src/files.ts)为元数据探测、根标记发现和有界读取维护了提供方与直接 Node 两条路径。`nodeStatFile`、`nodeTextChunks` 和可选的提供方/目标字段支持后者。导出的 `discoverBaselineInstructionFiles` 包装函数选择该路径;`loadBaselineInstructions` 则允许调用方通过省略提供方来使用它。 + +仓库中的每个生产调用方都传入提供方。[插件](../../../../packages/context/agent-instructions/src/index.ts)读取 `ctx.get('fs')`,缺失时直接返回;[协调逻辑](../../../../packages/context/agent-instructions/src/state.ts)要求传入 `FileSystem`。直接 Node 路径的消费方都是测试。因此,维护两条路径增加了独立的错误与取消覆盖,却没有服务于已交付的产品路径。 + +## 提案 + +在整个发现与加载链路中要求传入 `FileSystem`,包括两个导出的包装函数和 `findProjectRoot`。移除 `nodeStatFile`、`nodeTextChunks`、仅供 Node 使用的路径缺失分类、`existsAsMarker` 的 Node 分支以及 `statFile` 选择器。提供方发现过程给出必需的目标;有界读取通过 `streamText` 消费这些目标。 + +保留插件缺少提供方时不执行操作的行为,以及纯函数 `renderAgentInstructions` API。保留候选优先级、源文件与渲染输出的字节预算、取消、符号链接跟随和错误语义:候选不可用不能表示应移除已有内容,根标记失败仍是错误。独立的 `skill-filesystem` 所有者保留其现有的无提供方行为。 + +让直接调用辅助函数的测试改用显式管理生命周期的本地或受控提供方。[测试套件](../../../../packages/context/agent-instructions/tests/agent-instructions.spec.ts)已经挂载了这两类实现。保留独有断言,把重复的 Node mock 场景合并到提供方覆盖中。预计在更新签名和文档之前删除约 50–65 行生产源码,另加冗余测试机制;实施时测量实际减少量。 + +[符号链接决策](../../implemented/feature/2026-07-21-follow-instruction-symlinks.zh.md)所接受的行为继续存在,因此该说明保持现行状态。实施时,更新其中对两种实现的引用和[包 README](../../../../packages/context/agent-instructions/README.zh.md),包括双语对侧。[已归档的工作区上下文说明](../../archived/feature/2026-06-24-workspace-context.md)保持冻结;本提案不附带任何既有说明变更。 + +## 备选方案 + +**保留无提供方的便利导出。** 它们让外部调用方无需构造提供方便可加载指令,但也要求保留生产路径不使用的第二套 I/O 实现。本提案有意放弃这种便利;仓库搜索不能确定外部无人使用。 + +**隐式构造本地提供方。** 这会保留省略参数的调用方式,却隐藏文件系统选择和生命周期归属。调用方应传入它们打算使用的实现。 + +## 验收标准 + +- 本所有者的发现/读取辅助函数要求提供方,且不含直接 Node I/O 回退;无提供方的插件组合仍不加载任何内容。 +- 针对性测试保留符号链接、缺失/不可用、标记错误、预算、取消、恢复、嵌套访问和压缩(compaction)后恢复的行为。 +- 工作区上下文恢复的进程预期输出和 `ptc-workspace-context` 场景保持原有文本及事件顺序。类型检查、受影响构建和文档检查通过。 + +## 风险 + +省略提供方的外部调用方面临有意的稳定前 API 变更。实施前确认这类需求。保留文件系统扩展路径,也不要用重新创建已移除回退的新便利包装函数替换有用的提供方测试。 diff --git a/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.i18n.yaml new file mode 100644 index 0000000000..6360d57daa --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.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/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.md +2026-09-19-prune-stream-visibility-helpers.md: e44716fe15eb0ff09020ff4417b1dd9f4e9c29f4 +2026-09-19-prune-stream-visibility-helpers.zh.md: 60d010f260a6cadc99a15bde164bb0717dd7502a diff --git a/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.md b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.md new file mode 100644 index 0000000000..e44716fe15 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.md @@ -0,0 +1,35 @@ +# Agent Note: Prune unused stream visibility helpers + +Status: proposed + +English | [中文](2026-09-19-prune-stream-visibility-helpers.zh.md) + +## Problem + +The [compact Assistant stream implementation](../../../../packages/llm/llm/src/assistant-stream.ts) exports five visibility queries: `isVisibleChunk`, `chunkHasVisibleText`, `runFirstVisibleTime`, `assistantStreamHasVisibleContent`, and `assistantStreamHasVisibleText`. Repository searches find no production caller outside that family. Its other consumers are the [unit tests](../../../../packages/llm/llm/tests/assistant-stream.spec.ts), package documentation, and the [record-reader decision](../../implemented/architecture/2026-09-06-embedded-stream-record-readers.md). The package root and published `./assistant-stream` entry expose the functions to external callers, whose usage this search cannot establish. + +The retained first-token reader has current consumers in [Session Stats](../../../../packages/session/session-stats/src/projection.ts) and [Trajectory](../../../../packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts). Their timing requirements do not require the separate whitespace-sensitive visibility classification. + +## Proposal + +Remove the five exported visibility queries and their exclusive private helpers, `hasNonWhitespace` and `blockIsVisible`. This deletes approximately 77 source lines, including local documentation, without adding replacement machinery. Retain all first-token, raw-chunk, text-joining, and assembly readers, the accumulator, and validating expansion. Preserve the direct and library-backed LLM adapters and their extension APIs. + +Remove dedicated visibility tests and only the obsolete assertions inside mixed reader tests. Keep the mixed fixtures and assertions that verify first-token timing, early exit, raw chunks, text joining, and assembly equivalence. + +The record-reader decision is only partially superseded: its allocation rationale and used readers remain current. When implementing this proposal, update that active note, the [package README](../../../../packages/llm/llm/README.md), and the [LLM subsystem page](../../../../docs/subsystems/llm-streaming.md) with their bilingual counterparts. Do not delete the owning note or alter archived records while adding this proposal. + +## Alternatives considered + +**Keep a complete reader family for external consumers.** This preserves convenient, tested visibility queries, but keeps public functions and dedicated tests without a demonstrated repository consumer. A named external requirement would justify retaining or reintroducing them. + +**Replace visibility queries with stream expansion.** There is no production caller to migrate. Adding replacements would retain an unused API and introduce the allocations the record readers avoid. + +## Acceptance criteria + +- The five names disappear from source exports and current API documentation; no production caller gains an expansion or replacement visibility query. +- Focused stream-reader, Session Stats, and Trajectory timing checks pass; mixed tests retain their unrelated assertions. +- Typecheck, affected published-entry builds, and documentation checks pass. Session formats, model input, transcript content, and first-token timing remain unchanged. + +## Risks + +This deliberately contracts exported pre-stable APIs. An external import may break even though repository consumers are absent. Refresh consumer evidence before implementation and assess any identified external requirement explicitly; do not claim ecosystem-wide non-use. diff --git a/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.zh.md b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.zh.md new file mode 100644 index 0000000000..60d010f260 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-prune-stream-visibility-helpers.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 精简未使用的流可见性辅助函数 + +Status: proposed + +[English](2026-09-19-prune-stream-visibility-helpers.md) | 中文 + +## 问题 + +[紧凑 Assistant 流实现](../../../../packages/llm/llm/src/assistant-stream.ts)导出五个可见性查询:`isVisibleChunk`、`chunkHasVisibleText`、`runFirstVisibleTime`、`assistantStreamHasVisibleContent` 和 `assistantStreamHasVisibleText`。仓库搜索未找到这组函数之外的生产调用方。其余消费方是[单元测试](../../../../packages/llm/llm/tests/assistant-stream.spec.ts)、包文档和[记录读取器决策](../../implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md)。包根入口和发布的 `./assistant-stream` 入口向外部调用方暴露这些函数;本次搜索无法确定外部使用情况。 + +保留的首 token 读取器已有 [Session Stats](../../../../packages/session/session-stats/src/projection.ts) 和 [Trajectory](../../../../packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts) 消费方。它们的计时要求不依赖单独区分空白文本的可见性分类。 + +## 提案 + +移除这五个导出的可见性查询及其专用私有辅助函数 `hasNonWhitespace` 和 `blockIsVisible`。这会删除约 77 行源码(含局部文档),无需新增替代机制。保留所有首 token、原始分片、文本拼接和组装读取器,以及累加器和带校验的展开操作。保留直接实现和基于库实现的两个 LLM(大语言模型)适配器及其扩展 API。 + +移除专门的可见性测试;混合读取器测试只删除过时断言。保留验证首 token 计时、提前退出、原始分片、文本拼接和组装等价性的混合 fixture(测试前置数据)与断言。 + +记录读取器决策只被部分替代:其分配开销方面的理由和仍被使用的读取器继续有效。实施本提案时,更新该现行说明、[包 README](../../../../packages/llm/llm/README.zh.md) 和 [LLM 子系统页面](../../../../docs/subsystems/llm-streaming.zh.md)及其双语对侧。添加本提案时不删除所属说明,也不修改归档记录。 + +## 备选方案 + +**为外部消费方保留完整的读取器组。** 这能保留方便且经过测试的可见性查询,但也会在缺少已确认仓库消费方的情况下保留公共函数和专门测试。明确的外部需求可以成为保留或重新引入它们的理由。 + +**用流展开替代可见性查询。** 当前没有需要迁移的生产调用方。新增替代实现会继续保留未使用的 API,并引入记录读取器原本避免的分配开销。 + +## 验收标准 + +- 源码导出和现行 API 文档中不再包含这五个名称;生产调用方不新增展开操作或替代可见性查询。 +- 针对流读取器、Session Stats 和 Trajectory 计时的检查通过;混合测试保留无关断言。 +- 类型检查、受影响发布入口的构建和文档检查通过。Session 格式、模型输入、transcript(文本记录)内容和首 token 计时保持不变。 + +## 风险 + +这会有意收缩导出的稳定前 API。即使仓库中没有消费方,外部导入仍可能失效。实施前重新核对消费方证据,并明确评估发现的外部需求;不得宣称整个生态均未使用这些函数。 diff --git a/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.i18n.yaml new file mode 100644 index 0000000000..3920961bcf --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.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/proposed/simplification/2026-09-19-python-sdk-directional-client.md +2026-09-19-python-sdk-directional-client.md: 287126d65cc9e81e240c2b18081b2935c107760a +2026-09-19-python-sdk-directional-client.zh.md: caf9b7c73a00b8865b6d189fe512f96afb20dad9 diff --git a/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.md b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.md new file mode 100644 index 0000000000..287126d65c --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.md @@ -0,0 +1,34 @@ +# Agent Note: Remove the Python SDK's unused reverse RPC direction + +Status: proposed + +English | [中文](2026-09-19-python-sdk-directional-client.zh.md) + +## Problem + +The [Python client](../../../../python/sdk/src/deepseek_harness/client.py) keeps an incoming-request queue, close-time wake-up, and `next_request`, `respond`, `respond_error`, and `notify` methods. The [SDK server](../../../../packages/sdk/server/src/server.ts) receives requests and emits notifications; it declares no server-originated request or client-notification handler. Searches across Python sources, examples, and runtime profiles found the reverse direction only in client implementation and [synthetic client tests](../../../../python/sdk/tests/test_client.py). The exported `IncomingRequest` model consequently has no supported runtime producer. + +This is a narrower continuation of the [directional JSON-RPC proposal](../../rejected/simplification/2026-07-19-make-jsonrpc-directional.md), whose rejection explicitly permits transport narrowing separately. Its completion redesign remains rejected. Its claim about the shared TypeScript transport is now inapplicable: the [Codex wire adapter](../../../../packages/subagent/subagent-codex/src/wire.ts) uses requests and notifications in both directions. + +## Proposal + +Remove the four Python methods, `_requests`, and `IncomingRequest` from the client, models, and exports. Retain an explicit guard for an unexpected frame containing both `id` and `method` before response matching, so a server request cannot settle an unrelated pending client request. Keep client requests, response validation, incoming notification subscriptions, and shutdown unchanged. + +Remove the dedicated synthetic reverse-request test. Change tests that send client notifications only to trigger fixture behavior to use a request and response instead; retain their filter-exception and concurrent-write assertions. This removes one queue and its lifetime, four public methods, one public model, and roughly three dozen source lines without adding a replacement transport. + +## Alternatives considered + +**Keep a generic Python peer for external plugins.** Custom runtimes could use the methods, but no SDK method describes their request ownership, cancellation, or delivery protocol. The proposal accepts this pre-stable API loss; a future bidirectional SDK feature must supply its actual method and lifecycle requirements. + +**Narrow the shared TypeScript transport too.** Rejected because the Codex adapter is a current bidirectional consumer. The shared implementation and its tests remain. + +## Acceptance criteria + +- Python sources and examples have no reverse-direction API or request queue; documentation and exports describe the retained client role. +- An unexpected server request reusing a pending client id cannot complete its waiter. Notification filtering, sibling delivery, final draining, malformed-frame handling, serialized writes, and shutdown still pass their existing tests. +- Prompt submission still returns the durable enqueue receipt; no per-prompt completion promise or response-based turn result is introduced. +- Run the Python client suite and installed-runtime SDK smoke and snapshot checks, plus documentation and lint checks. Preserve the TypeScript transport and released Session fixtures. + +## Risks + +External Python callers using custom runtime plugins lose these methods. Ignoring an unsupported request does not fulfill a hypothetical server's response expectation; this is deliberate withdrawal of that direction, not protocol parity. Revisit the proposal if a supported server-originated method lands before implementation. diff --git a/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.zh.md b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.zh.md new file mode 100644 index 0000000000..caf9b7c73a --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-python-sdk-directional-client.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 删除 Python SDK 未使用的反向 RPC + +Status: proposed + +[English](2026-09-19-python-sdk-directional-client.md) | 中文 + +## 问题 + +[Python 客户端](../../../../python/sdk/src/deepseek_harness/client.py) 维护入站请求队列、关闭时的唤醒,以及 `next_request`、`respond`、`respond_error` 和 `notify` 方法。[SDK 服务端](../../../../packages/sdk/server/src/server.ts) 接收请求并发送通知,没有定义服务端主动发起的请求或客户端通知处理器。搜索 Python 源码、示例和运行时 profile 后,仅在客户端实现和[合成客户端测试](../../../../python/sdk/tests/test_client.py) 中发现反向通信。因此,导出的 `IncomingRequest` 模型没有受支持运行时的生产方。 + +这是[定向 JSON-RPC 提案](../../rejected/simplification/2026-07-19-make-jsonrpc-directional.zh.md) 的缩小范围续案;该提案的否决原因明确允许单独重提传输收缩。其完成语义重设计仍被否决。它对共享 TypeScript 传输的判断已不适用:[Codex wire 适配器](../../../../packages/subagent/subagent-codex/src/wire.ts) 在两个方向上都使用请求和通知。 + +## 提案 + +从客户端、模型和导出中删除四个 Python 方法、`_requests` 和 `IncomingRequest`。在匹配响应前,保留对同时含有 `id` 和 `method` 的意外帧的显式保护,避免服务端请求完成无关的待处理客户端请求。保持客户端请求、响应校验、入站通知订阅和关闭行为。 + +删除专门的合成反向请求测试。仅为触发 fixture(测试前置数据)行为而发送客户端通知的测试改用请求和响应,同时保留过滤器异常与并发写入断言。该变更删除一个队列及其生命周期、四个公共方法、一个公共模型和约三十多行源码,不增加替代传输实现。 + +## 考虑过的替代方案 + +**保留通用 Python 对等端供外部插件使用。** 自定义运行时可能使用这些方法,但没有 SDK 方法规定其请求所有权、取消或交付协议。提案接受这一稳定前 API 损失;未来双向 SDK 功能必须提供实际的方法与生命周期要求。 + +**同时收缩共享 TypeScript 传输。** 否决,因为 Codex 适配器是当前双向消费方。共享实现及其测试保留。 + +## 验收标准 + +- Python 源码和示例不再包含反向 API 或请求队列;文档和导出描述保留的客户端角色。 +- 复用待处理客户端 id 的意外服务端请求不能完成其等待方。通知过滤、同级交付、末尾排空、错误帧处理、串行写入和关闭仍通过现有测试。 +- 提交提示仍返回持久化入队回执;不引入单条提示的完成承诺或基于响应的轮次结果。 +- 运行 Python 客户端测试、已安装运行时的 SDK 冒烟与快照检查,以及文档和 lint 检查。保留 TypeScript 传输和已发布 Session fixture。 + +## 风险 + +使用自定义运行时插件的外部 Python 调用方会失去这些方法。忽略不支持的请求无法满足假想服务端的响应期待;这是有意撤回该方向,而非保持协议等价。如果受支持的服务端主动请求方法先于实现落地,需要重新评估提案。 diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.i18n.yaml new file mode 100644 index 0000000000..d11c6bed98 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.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/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.md +2026-09-19-retire-app-bin-boot-helpers.md: c67013bfae8569d0f750b3c8be5e56c57a2a66aa +2026-09-19-retire-app-bin-boot-helpers.zh.md: 5ef68ff3d8666e8b74d36c50eacbcf5048a4438a diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.md b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.md new file mode 100644 index 0000000000..c67013bfae --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.md @@ -0,0 +1,36 @@ +# Agent Note: Retire app-boot helpers used only by fixtures + +Status: proposed + +English | [中文](2026-09-19-retire-app-bin-boot-helpers.zh.md) + +## Problem + +[App boot](../../../../packages/boot/app-boot/src/index.ts) exports `resolveConfigPath`, including a basename-specific replay substitution, and `loadEnv`, which reads one optional `.env`. The supported [CLI launcher](../../../../apps/cli/src/bin.ts) uses `loadLayeredEnv`; [profile boot](../../../../apps/cli/src/profile-boot.ts) supplies an absolute path directly. Neither production path calls the older helpers. + +An exact-symbol search across packages, apps, and scripts finds ten `resolveConfigPath` calls in eight fixture drivers, all passing `undefined` for replay mode. The only non-unit `loadEnv` caller is the [Loader smoke fixture](../../../../packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts). The [snapshot launcher](../../../../packages/test-support/session-snapshot/src/launcher.ts) separately owns replay patch selection. The old helpers consequently retain two public APIs, unused production replay policy, and dedicated tests for fixture-only needs. + +The [shared app-bin glue record](../../archived/simplification/2026-07-04-share-app-bin-boot-glue.md) explains the original shared-bin use. The active [single-launcher decision](../../implemented/architecture/2026-08-22-single-dsh-application-launcher.md) replaces those supported entry paths. This is partial supersession: `boot` and fail-loud handling still have owners, and the archived record remains frozen. + +## Proposal + +Delete `resolveConfigPath` and `loadEnv` from the production export. Use Node's `path.resolve` in the eight fixture drivers. Keep optional one-directory `.env` handling local to the sole fixture that needs it, including its absent-file and warning behavior; do not introduce a replacement production package. + +Remove helper-specific tests and update app-boot's package description, README pair, and `boot` JSDoc. Keep `boot`, `loadLayeredEnv`, shipped profile composition, and snapshot replay selection. The identified removal is 39 production source/JSDoc lines and about 73 dedicated test lines, less the small fixture-local environment handling and builtin imports; the estimate is not an implemented diff. + +## Alternatives considered + +**Keep public conveniences for low-level embedders.** They can be useful, but current fixtures request only path resolution and one optional file read. Maintaining a second production replay selector and obsolete app-bin guidance is disproportionate to those uses. + +**Replace every use with `loadLayeredEnv`.** Rejected because layered discovery, filtering, and precedence differ from reading one fixture-local `.env`. Sharing a name does not establish equivalent behavior. + +## Acceptance criteria + +- Exact-name searches find no live helper imports or calls. All ten path replacements remain absolute and select the same files. +- The sole environment-loading fixture preserves missing-file, present-file, and warning behavior; helper-only tests disappear without dropping retained boot coverage. +- Run focused app-boot and affected fixture-entry tests, including built entry checks where required, a shipped profile smoke, and a keyless replay scenario. +- Update README pairs and package/JSDoc descriptions; run build, relevant hygiene, typecheck, doc-sync, and lint. Add no compatibility aliases or second replay selector. + +## Risks + +Out-of-tree callers importing these pre-stable helpers must use the retained profile APIs or own their low-level path/environment policy. Accidentally switching the fixture to layered environment discovery would change its test inputs; preserve that distinction explicitly. diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.zh.md b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.zh.md new file mode 100644 index 0000000000..5ef68ff3d8 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-retire-app-bin-boot-helpers.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 删除仅供 fixture 使用的 app-boot 辅助函数 + +Status: proposed + +[English](2026-09-19-retire-app-bin-boot-helpers.md) | 中文 + +## 问题 + +[App boot](../../../../packages/boot/app-boot/src/index.ts) 导出 `resolveConfigPath`,其中包含按文件名替换重放配置的逻辑,以及读取单个可选 `.env` 的 `loadEnv`。受支持的 [CLI 启动器](../../../../apps/cli/src/bin.ts) 使用 `loadLayeredEnv`;[profile boot](../../../../apps/cli/src/profile-boot.ts) 直接提供绝对路径。这两条生产路径都不调用旧辅助函数。 + +在 packages、apps 和 scripts 中搜索精确符号,可找到八个 fixture(测试前置数据)驱动器中的十次 `resolveConfigPath` 调用,重放模式全部传入 `undefined`。唯一非单元测试的 `loadEnv` 调用方是 [Loader 冒烟 fixture](../../../../packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts)。[快照启动器](../../../../packages/test-support/session-snapshot/src/launcher.ts) 单独拥有重放补丁选择。因此,旧辅助函数仅为 fixture 的需要保留了两个公共 API、生产中未使用的重放策略及专用测试。 + +[共享 app-bin 胶水代码记录](../../archived/simplification/2026-07-04-share-app-bin-boot-glue.md) 解释了最初的共享可执行入口用途。当前[单一启动器决策](../../implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md) 已替代这些受支持入口。这属于部分取代:`boot` 和明确失败处理仍有归属,归档记录保持冻结。 + +## 提案 + +从生产导出中删除 `resolveConfigPath` 和 `loadEnv`。八个 fixture 驱动器改用 Node 的 `path.resolve`。仅在需要它的 fixture 内保留读取单目录可选 `.env` 的处理,包括文件缺失与警告行为;不引入替代生产包。 + +删除辅助函数专用测试,并更新 app-boot 的包描述、README 配对及 `boot` JSDoc。保留 `boot`、`loadLayeredEnv`、随产品发布的 profile 组合和快照重放选择。已定位可删除 39 行生产源码/JSDoc 和约 73 行专用测试,需扣除少量 fixture 本地环境处理及内置模块导入;这是估算,尚非实现 diff。 + +## 考虑过的替代方案 + +**为底层嵌入方保留公共便利函数。** 它们可能有用,但当前 fixture 仅需要路径解析和单个可选文件读取。为这些用途维护第二套生产重放选择器及过时的 app-bin 指南,成本过高。 + +**将所有调用替换为 `loadLayeredEnv`。** 否决,因为分层发现、过滤和优先级不同于读取单个 fixture 本地 `.env`。名称相近不能证明行为等价。 + +## 验收标准 + +- 精确名称搜索不再发现有效的辅助函数导入或调用。十处路径替换仍为绝对路径并选择相同文件。 +- 唯一加载环境的 fixture 保持文件缺失、文件存在和警告行为;删除辅助函数专用测试时不丢失保留的 boot 覆盖。 +- 运行聚焦的 app-boot 和受影响 fixture 入口测试,包括所需的构建产物入口检查、随产品发布的 profile 冒烟,以及无密钥重放场景。 +- 更新 README 配对及包/JSDoc 描述;运行 build、相关 hygiene、typecheck、doc-sync(文档同步门禁)和 lint。不添加兼容别名或第二个重放选择器。 + +## 风险 + +导入这些稳定前辅助函数的仓库外调用方,需要改用保留的 profile API,或自行拥有底层路径与环境策略。意外将 fixture 改为分层环境发现会改变其测试输入;必须明确保留这一差别。 diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.i18n.yaml new file mode 100644 index 0000000000..625e054ecf --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.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/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md +2026-09-19-retire-prompt-registry-change-event.md: 1103675525e30c7f6c345a1fe92f07cbd0bcce4a +2026-09-19-retire-prompt-registry-change-event.zh.md: a49df2eebad7134b7b038ee2e6ab54610bd4c635 diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md new file mode 100644 index 0000000000..1103675525 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.md @@ -0,0 +1,35 @@ +# Agent Note: Retire the prompt registry change event + +Status: proposed + +English | [中文](2026-09-19-retire-prompt-registry-change-event.zh.md) + +## Problem + +[`SystemPrompt`](../../../../packages/core/system-prompt/src/index.ts) emits `system-prompt/change` when providers register or dispose. Repository searches find no executing product listener, but generated Host discovery advertises the event. Tests maintain notification counts and rollback after listener exceptions. This is a public extension with maintenance obligations, even without a first-party subscriber. + +The active [Remote event delivery decision](../../implemented/architecture/2026-08-10-remote-event-delivery.md) explicitly retains this extension despite having no shipped consumer. Per-step assembly predates that promise and serves a different purpose: rebuilding model input does not replace push observation of registry changes. + +## Proposal + +Retire `system-prompt/change` and accept the loss of prompt-registry push observation for installed and dynamically authored Host plugins. Remove its declaration, emitter, current documentation, generated discovery entries, and notification-specific tests. No currently supported product path requires this extension. + +Make [`ScopedLayers`](../../../../packages/core/scope/src/store.ts)'s constructor notification callback optional and omit it for `SystemPrompt`; do not supply a no-op callback. Remove the existing no-op arguments in [jobs-local](../../../../packages/jobs/jobs-local/src/index.ts) and [mcp-resources](../../../../packages/mcp/mcp-resources/src/index.ts) when updating this shared constructor, and update jobs-local's adjacent notification explanation. Preserve shared effects, action rollback, undo ordering, layer reclamation, and `tools/change` with its production consumers. Keep prompt-provider evaluation, scope shadowing, and assembly invariants. + +Implementation must amend the active Remote event delivery note to state that this proposal supersedes only its promise to retain `system-prompt/change`. Remove that event from the retained-extension statement and link the implemented retirement decision. Keep the Remote delivery note active and cross-linked: its forwarding policy and other extensions remain independently useful. Do not rewrite or archive that entire decision. + +## Alternatives considered + +**Keep the extension for future dashboards or plugins.** Push observation could support a real consumer, but none is identified in supported product paths. Retention preserves notification and listener-failure behavior that the package otherwise does not need. + +**Treat per-step assembly as proof of compatibility.** Rejected because model-input reconstruction and registry notifications are different behaviors. This proposal deliberately changes the extension promise rather than claiming the earlier reconstruction decision already removed it. + +## Acceptance criteria + +- Remove the event from source and current generated inventories, and amend the active retention promise in the implementation change. +- Remove notification-count and notification-failure tests; retain provider-membership, disposer, duplicate-registration, shadowing, and HMR coverage. Keep mixed tests' independent assertions. +- Run focused scope, tools, system-prompt, jobs-local, and mcp-resources tests, loop request-reconstruction coverage, a relevant keyless recording, catalog generation, typecheck, lint, and doc-sync. Prompt/log output remains unchanged. + +## Risks + +Installed plugins may depend on notifications or on listener exceptions rejecting registration. They lose both behaviors; repository searches cannot establish their absence. A future push consumer needs an explicit registration-observation requirement and ownership design before reintroducing an event. Source savings are small; the benefit is retiring the public notification and failure obligation. diff --git a/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.zh.md b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.zh.md new file mode 100644 index 0000000000..a49df2eeba --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-retire-prompt-registry-change-event.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 停用提示词注册表变更事件 + +Status: proposed + +[English](2026-09-19-retire-prompt-registry-change-event.md) | 中文 + +## 问题 + +[`SystemPrompt`](../../../../packages/core/system-prompt/src/index.ts) 在提供方注册或 dispose(资源释放)时发出 `system-prompt/change`。仓库搜索未找到执行的产品监听者,但生成的 Host 发现目录会展示该事件。测试维护通知次数及监听者抛出异常后的回滚行为。即使没有第一方订阅者,它仍是具有维护义务的公开扩展。 + +活动的 [Remote 事件投递决策](../../implemented/architecture/2026-08-10-remote-event-delivery.zh.md) 明确保留了这一扩展,尽管没有随产品提供的消费方。逐步骤组装早于该承诺,且用途不同:重新构建模型输入不能替代注册表变更的推送观测。 + +## 提案 + +停用 `system-prompt/change`,接受已安装及动态编写的 Host 插件失去提示词注册表推送观测的代价。删除其声明、发出点、当前文档、生成的发现条目和通知专属测试。当前受支持的产品路径均不依赖此扩展。 + +将 [`ScopedLayers`](../../../../packages/core/scope/src/store.ts) 构造函数的通知回调改为可选,并让 `SystemPrompt` 省略它;不提供无操作回调。更新此共享构造函数时,同时删除 [jobs-local](../../../../packages/jobs/jobs-local/src/index.ts) 和 [mcp-resources](../../../../packages/mcp/mcp-resources/src/index.ts) 中现有的无操作实参,并更新 jobs-local 相邻的通知说明。保留共享 effect、操作回滚、撤销顺序、层回收,以及 `tools/change` 及其生产消费方。保留提示词提供方求值、作用域覆盖和组装 invariant。 + +实现时必须修订活动的 Remote 事件投递记录,说明本提案只取代其中保留 `system-prompt/change` 的承诺。从保留扩展的陈述中移除该事件,并链接到已实现的停用决策。让 Remote 投递记录保持活动并相互链接:其转发策略和其他扩展仍具有独立价值。不要重写或归档整个决策。 + +## 考虑过的替代方案 + +**为未来仪表盘或插件保留扩展。** 推送观测可能服务于真实消费方,但受支持的产品路径中尚未找到这样的消费方。保留它会维持这个包原本不需要的通知及监听者失败行为。 + +**将逐步骤组装视为兼容性证据。** 不采用,因为模型输入重建和注册表通知是不同的行为。本提案有意改变扩展承诺,而不声称早先的重建决策已经删除了它。 + +## 验收标准 + +- 从源代码和当前生成目录中删除该事件,并在实现变更中修订活动记录中的保留承诺。 +- 删除通知次数和通知失败测试;保留提供方成员关系、disposer、重复注册、作用域覆盖及 HMR(热模块替换)覆盖。保留混合测试中的独立断言。 +- 运行针对作用域、工具、系统提示词、jobs-local 和 mcp-resources 的测试、循环请求重建覆盖、相关无密钥录制回放、目录生成、类型检查、lint 和 doc-sync(文档同步门禁)。提示词和日志输出保持不变。 + +## 风险 + +已安装插件可能依赖通知,或依赖监听者异常使注册失败。这两种行为都会消失;仓库搜索无法确认此类插件不存在。未来的推送消费方需要先明确注册观测需求和所有权设计,才能重新引入事件。源代码节省不多;收益在于停用公开通知及其失败义务。 diff --git a/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.i18n.yaml new file mode 100644 index 0000000000..bed94c3f36 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.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/proposed/simplification/2026-09-19-shell-env-key-declarations.md +2026-09-19-shell-env-key-declarations.md: 7b74e55e6b8602cb488d16040d57fe41d16e14da +2026-09-19-shell-env-key-declarations.zh.md: 03d7ed9fdee1c1181c529c2b2dd26ceb7c791ef2 diff --git a/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.md b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.md new file mode 100644 index 0000000000..7b74e55e6b --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.md @@ -0,0 +1,34 @@ +# Agent Note: Keep shell-environment declarations focused on key ownership + +Status: proposed + +English | [中文](2026-09-19-shell-env-key-declarations.zh.md) + +## Problem + +The [shell-environment registry](../../../../packages/shell/shell-env/src/index.ts) requires a description for every declared variable and exposes `list()` to enumerate it. Runtime collection uses only contributor identity, declared keys, and resolved values. [Bash](../../../../packages/shell/tool-bash/src/index.ts) and [PowerShell](../../../../packages/shell/tool-pwsh/src/index.ts) call `collect`; the [Web bundle](../../../../packages/bundle/web-app/src/index.ts) contributes `DSH_WEB_URL`. Searches found no fixed production caller of `list()` or consumer of its descriptions. + +The method is nevertheless discoverable through the [Cordis inspection provider](../../../../packages/extensions/tool-cordis/src/providers.ts), so this is an explicit API contraction, not unreachable-code removal. The [original identity-and-log-location record](../../archived/feature/2026-07-10-agent-session-identity-and-log-location.md) anticipated diagnostics and future prompt/UI consumers. The current TODO and [README limitation](../../../../packages/shell/shell-env/README.md) still describe an incomplete catalog that omits built-in variables. + +## Proposal + +Represent a contributor's declaration as an explicit readonly collection of keys. Remove description objects, `BashEnvVariable`, `BashEnvVariableInfo`, `list()`, description-only validation, and the exhaustive-catalog TODO. Update the Web contributor, README pair, and generated service/type declarations together. + +Keep `register`, `collect`, reserved keys, ownership conflicts, undeclared-output refusal, deterministic environment output, and effect disposal. The [PowerShell parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) continues to own the shared registry and both shell consumers. The proposal removes one public method, two metadata types, and their validation/enumeration tests; roughly 32 identified source lines disappear before the small key-membership adaptation. + +## Alternatives considered + +**Finish the diagnostics catalog.** That would add built-in metadata and a consumer capability without an identified current product requirement. The proposal instead gives up declarative environment descriptions and enumeration; actual shell environment values remain available through execution. + +**Keep descriptions after deleting `list()`.** Rejected because they would have no remaining reader while still burdening every contributor. Preserve the key ownership declaration independently of presentation metadata. + +## Acceptance criteria + +- Recheck fixed callers, recordings, generic inspection, and current product requirements before implementation; retain the API if an actual diagnostics consumer now needs it. +- Contributors declare keys without description objects. Duplicate names, reserved keys, cross-contributor conflicts, undeclared outputs, and disposal retain their behavior. +- Bash and PowerShell receive equivalent environment maps, including Web URL and Session identity; no secret-handling or ambient-environment policy changes. +- Run registry, Web contributor, and shell integration tests; regenerate and validate inspection catalogs and any affected recorded inspection output; update both READMEs and run typecheck, doc-sync, and lint. + +## Risks + +Model-written and external plugins can already discover and call `list()`. They lose enumeration without resolver execution and must change contributor declarations. This pre-stable API loss is acceptable only while the anticipated diagnostics capability remains unowned; `collect()` is not advertised as an equivalent diagnostic replacement. diff --git a/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.zh.md b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.zh.md new file mode 100644 index 0000000000..03d7ed9fde --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-shell-env-key-declarations.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 让 shell 环境声明只负责键所有权 + +Status: proposed + +[English](2026-09-19-shell-env-key-declarations.md) | 中文 + +## 问题 + +[shell 环境注册表](../../../../packages/shell/shell-env/src/index.ts) 要求每个声明变量提供描述,并暴露 `list()` 枚举它。运行时收集仅使用贡献方身份、声明键和解析后的值。[Bash](../../../../packages/shell/tool-bash/src/index.ts) 与 [PowerShell](../../../../packages/shell/tool-pwsh/src/index.ts) 调用 `collect`;[Web 组合包](../../../../packages/bundle/web-app/src/index.ts) 贡献 `DSH_WEB_URL`。搜索未发现 `list()` 的固定生产调用方或其描述的消费方。 + +但该方法可通过 [Cordis 检查提供方](../../../../packages/extensions/tool-cordis/src/providers.ts) 发现,因此这是显式 API 收缩,而非删除不可达代码。[最初的身份与日志位置记录](../../archived/feature/2026-07-10-agent-session-identity-and-log-location.md) 预期它会服务于诊断以及未来提示/UI 消费方。当前 TODO 与 [README 限制](../../../../packages/shell/shell-env/README.zh.md) 仍描述一个未包含内置变量的不完整目录。 + +## 提案 + +将贡献方声明表示为显式只读键集合。删除描述对象、`BashEnvVariable`、`BashEnvVariableInfo`、`list()`、仅针对描述的校验,以及完整目录 TODO。同时更新 Web 贡献方、README 配对与生成的服务/类型声明。 + +保留 `register`、`collect`、保留键、所有权冲突、拒绝未声明输出、确定性的环境输出和 effect 释放。[PowerShell 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md) 继续拥有共享注册表及两个 shell 消费方。提案删除一个公共方法、两个元数据类型及其校验/枚举测试;在少量键成员判定适配前,已定位可删除约 32 行源码。 + +## 考虑过的替代方案 + +**补完诊断目录。** 这会在没有明确当前产品需求的情况下增加内置元数据及消费能力。提案选择放弃声明式环境描述和枚举;实际 shell 环境值仍可通过执行获得。 + +**删除 `list()` 后保留描述。** 否决,因为描述将没有剩余读取方,却仍给每个贡献方增加负担。键所有权声明应独立于展示元数据保留。 + +## 验收标准 + +- 实现前重新检查固定调用方、记录、通用检查与当前产品要求;若已有实际诊断消费方需要,则保留 API。 +- 贡献方声明键而无需描述对象。重复名称、保留键、跨贡献方冲突、未声明输出及释放保持原有行为。 +- Bash 与 PowerShell 获得等价的环境映射,包括 Web URL 和 Session 身份;不改变密钥处理或继承环境策略。 +- 运行注册表、Web 贡献方与 shell 集成测试;重新生成并校验检查目录及受影响的检查输出记录;更新两个 README,运行 typecheck、doc-sync(文档同步门禁)和 lint。 + +## 风险 + +模型编写的插件与外部插件已经能够发现和调用 `list()`。它们会失去不执行解析器的枚举能力,并需要修改贡献方声明。只有预期的诊断能力仍无归属时,这种稳定前 API 损失才可接受;不能将 `collect()` 宣称为等价诊断替代。 diff --git a/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.i18n.yaml new file mode 100644 index 0000000000..676a701835 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.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/proposed/simplification/2026-09-19-trim-session-query-convenience-api.md +2026-09-19-trim-session-query-convenience-api.md: 1a3d558c9be5e16b5e9e84a59a9731b55174114c +2026-09-19-trim-session-query-convenience-api.zh.md: 63c3ae309edee8366eae4d5db10cc59f0b72e843 diff --git a/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.md b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.md new file mode 100644 index 0000000000..1a3d558c9b --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.md @@ -0,0 +1,35 @@ +# Agent Note: Trim unused SessionQuery convenience methods + +Status: proposed + +English | [中文](2026-09-19-trim-session-query-convenience-api.zh.md) + +## Problem + +[`SessionQueryEngine`](../../../../packages/session-query/session-query/src/index.ts) maintains `readSession`, `readTitle`, `readTitleSnapshot`, and `listEvents` without executing first-party product callers. Exact-name and call-site searches find internal delegation, tests, and generated discovery metadata. Current consumers use retained observations, batch titles, surface reads, and traces. The generated catalog exposes these methods to plugin authors but does not execute the advertised operations. + +These entry points add replay-validation and result-copying paths, wrappers, public types, and endpoint-specific tests. Their removal can reduce maintained API obligations without replacing the shared query implementation. + +## Proposal + +Remove the four methods, the unreferenced [`SessionLogSnapshot`](../../../../packages/session-query/session-query/src/types.ts) result type, and [`eventRecords`](../../../../packages/session-query/session-query/src/tracing.ts). Remove their imports and current documentation, including the `SessionLogSnapshot` type-equivalence block and explanatory paragraph in both subsystem pages. Remove its hand-maintained entry in the [type-equivalence manifest](../../../../scripts/type-equiv.manifest.json) and its `LINK_MAP` entry in the [catalog generator](../../../../scripts/gen-cordis-catalog.ts), then regenerate discovery metadata. The selected definitions occupy approximately eighty source lines before import and metadata cleanup; no replacement service is needed. + +Retain `filterEvents` and `_filterEvents`, together with their semantic extraction and filtering helpers. Retain `observeSession`, `readTitleSnapshots`, `readSurface`, `readEvent`, listing, search, and tracing. Preserve shared corpus resolution and its existing ownership and failure behavior. + +The [historical unified-query decision](../../archived/architecture/2026-07-23-unified-session-query-service.md) explains the retained single-service topology. This proposal removes selected convenience operations, not that topology. No active note is fully superseded; keep archived records frozen and link this narrower decision from affected current documentation. + +## Alternatives considered + +**Retain all documented methods for external callers.** This avoids migration for installed plugins, but keeps four APIs without a current product caller. The pre-stable API policy permits a deliberate removal with explicit migration costs. + +**Remove `filterEvents` too.** Rejected because its provider-independent literal substring scan differs from FTS token matching. The [SQLite query documentation](../../../../packages/session-query/session-query-sqlite/README.md) explicitly recommends this operation; removing it would withdraw a distinct query capability. + +## Acceptance criteria + +- Remove the four methods and orphan definitions from source, package READMEs, subsystem declarations, and generated catalogs; retain literal substring queries. +- Preserve live-source precedence, corruption refusal, cancellation, detached results, cold reads, and batch-title behavior through retained endpoints. Move relevant assertions from removed-method tests instead of deleting their shared guarantees; account for `readSession`'s additional replay validation. +- Run focused query, SQLite-query, tool-query, and affected context-reference tests, relevant keyless query recordings, catalog generation, typecheck, lint, and doc-sync. Released Session generations remain unchanged. + +## Risks + +Repository searches cannot enumerate installed or dynamically authored plugins. Such callers lose complete-log and lightweight-record convenience operations and must adopt retained APIs with their actual ownership and error semantics. A demonstrated consumer requiring a removed operation would justify reconsidering that operation separately. diff --git a/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.zh.md b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.zh.md new file mode 100644 index 0000000000..63c3ae309e --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-19-trim-session-query-convenience-api.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 精简未使用的 SessionQuery 便利方法 + +Status: proposed + +[English](2026-09-19-trim-session-query-convenience-api.md) | 中文 + +## 问题 + +[`SessionQueryEngine`](../../../../packages/session-query/session-query/src/index.ts) 维护了 `readSession`、`readTitle`、`readTitleSnapshot` 和 `listEvents`,但没有执行这些方法的第一方产品调用方。精确名称和调用点搜索只找到内部委托、测试和生成的发现元数据。当前消费方使用可保留的观测、批量标题、模型可见事件读取和追踪。生成的目录向插件作者展示这些方法,但不执行其中列出的操作。 + +这些入口增加了回放验证和结果复制路径、包装层、公开类型及入口专属测试。删除它们可以减少需要维护的 API 义务,无须替换共享查询实现。 + +## 提案 + +删除这四个方法、不再被引用的 [`SessionLogSnapshot`](../../../../packages/session-query/session-query/src/types.ts) 结果类型和 [`eventRecords`](../../../../packages/session-query/session-query/src/tracing.ts)。删除相关导入及当前文档,包括两个子系统页面中的 `SessionLogSnapshot` 类型等价代码块和说明段落。删除[类型等价 manifest](../../../../scripts/type-equiv.manifest.json) 中手工维护的条目,以及[目录生成器](../../../../scripts/gen-cordis-catalog.ts) 中的 `LINK_MAP` 条目,然后重新生成发现元数据。选定定义在清理导入和元数据之前约占八十行源代码;无须新增替代服务。 + +保留 `filterEvents` 和 `_filterEvents`,以及语义提取和过滤辅助函数。保留 `observeSession`、`readTitleSnapshots`、`readSurface`、`readEvent`、列举、搜索和追踪。保留共享会话集合解析及其现有所有权和失败行为。 + +[历史上的统一查询决策](../../archived/architecture/2026-07-23-unified-session-query-service.md) 解释了保留的单服务结构。本提案删除选定的便利操作,不改变该结构。没有活动记录被完全取代;保持归档记录冻结,并从受影响的当前文档链接到这一较窄的决策。 + +## 考虑过的替代方案 + +**为外部调用方保留所有已记录的方法。** 这样可以避免已安装插件迁移,但会保留四个没有当前产品调用方的 API。尚未稳定的 API 策略允许在明确迁移代价后有意删除它们。 + +**同时删除 `filterEvents`。** 不采用,因为其提供方无关的字面子串扫描不同于 FTS 的 token 匹配。[SQLite 查询文档](../../../../packages/session-query/session-query-sqlite/README.zh.md) 明确推荐该操作;删除它会撤销一项独立的查询能力。 + +## 验收标准 + +- 从源代码、包 README、子系统声明和生成目录中删除这四个方法及失去引用的定义;保留字面子串查询。 +- 通过保留的入口维持实时来源优先、损坏拒绝、取消、结果独立复制、冷读取和批量标题行为。将被删方法测试中的相关断言迁移出去,不删除其共享保证;明确处理 `readSession` 额外执行的回放验证。 +- 运行针对查询、SQLite 查询、查询工具及受影响的会话引用的测试,相关无密钥查询录制回放、目录生成、类型检查、lint 和 doc-sync(文档同步门禁)。已发布的 Session 代际文件保持不变。 + +## 风险 + +仓库搜索无法枚举已安装或动态编写的插件。这些调用方将失去完整日志和轻量事件记录的便利操作,必须按照保留 API 实际的所有权和错误语义迁移。若出现确实需要某个被删操作的消费方,可以单独重新考虑该操作。 diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index bce148ad18..53444db411 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -1,157 +1,67 @@ --- name: dsh-find-simplifications -description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates, remove redundant comments or implementation-heavy documentation, write proposed Agent Notes or inline TODO/FIXME/XXX notes, audit or coalesce superseded Agent Notes, or fold worthwhile simplification ideas from another PR; especially for dead, duplicated, speculative, over-built, added-then-removed, or hand-rolled-where-a-dependency-exists surfaces.' +description: Find evidence-backed simplifications in DeepSeek Harness code, APIs, configuration, tests, and prose; write or consolidate proposals, identify small inline cleanups, or assess simplifications from another branch. Use for removing dead, duplicated, speculative, or unnecessarily maintained behavior and infrastructure. --- # Finding DeepSeek Harness Simplifications -This skill helps turn a broad "find things to simplify" request into evidence-backed Agent Notes that remove or collapse existing harness surface area. It is guidance, not a checklist: follow the code, keep judgment active, and prefer a few well-proven candidates over a pile of thin guesses. +Find changes that remove maintained obligations: APIs, representations, lifecycle states, configuration paths, dependencies, tests, or documentation. Prefer a few well-supported candidates over a count of deletions. This is guidance, not a checklist; keep the user's scope and distinguish a survey from permission to implement its proposals. -## Start With Repo Context +## Establish scope and constraints -- Read `AGENTS.md`, especially the pre-release stance and the conventions (including the tests-are-not-golden-truth and Agent Notes-are-not-golden-truth doctrines), plus [docs/defensive-patterns.md](../../../docs/defensive-patterns.md) and [docs/testing.md](../../../docs/testing.md). -- Skim [docs/architecture.md](../../../docs/architecture.md) before judging anything under `packages/`; simplifications that fight the service map or event taxonomy need extra evidence. -- Use the Agent Note tree and its [rules](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/archived/simplification/2026-06-19-drop-mutable-session-summary.md), [handle-based session persistence](../../notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md), [JSONL-only first-party Session persistence](../../notes/implemented/simplification/2026-08-30-jsonl-only-session-persistence.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin-adapter Agent Notes. -- Treat dual LLM adapters as intentional by default. Session persistence is different: JSONL is the sole first-party provider, while the backend-neutral service remains available to out-of-tree providers. Do not propose deleting an LLM twin or the persistence seam as "low effort" unless the user explicitly overrides that constraint. Removing an unused method or hook inside a protected seam can still be valid if it does not collapse the protected design. +Read `AGENTS.md`, [architecture](../../../docs/architecture.md) before judging packages, [defensive patterns](../../../docs/defensive-patterns.md), and [testing policy](../../../docs/testing.md). Consult the relevant active Agent Notes for intentional decisions; use the [note rules](../../notes/README.md) for new records. Archived notes are historical examples, not current authority: never inspect, verify, or repair their outbound links. -## What Counts As A Strong Candidate +The two LLM adapters are intentional. JSONL is the sole first-party Session persistence provider, while the backend-neutral Service Definition supports out-of-tree providers. Do not propose deleting an LLM twin or the persistence seam unless the user explicitly overrides that constraint. Unused members within a protected design remain candidates when their removal preserves its purpose. -A strong simplification removes, folds, or demotes something real and has clear evidence that the current design costs more than it buys: +For broad requests, divide independent domains among subagents: loop and persistence; model/tool assembly; Host, Client, and SDKs; subprocess and execution providers; composition, scripts, tests, and documentation. Give each agent a bounded domain and require consumer evidence and rejected alternatives. Inspect substantial production machinery as well as obvious unused symbols; do not stop after the first promising deletion. -- A public method, event, config knob, registry notification, helper, package, durable event, or test artifact has no production consumer. -- Tests or docs are the only consumers, and the behavior they pin is not load-bearing. -- Two representations mirror the same fact, especially across durable session events and transient `agent/*` events. -- A seam has methods every implementation must support but no consumer uses. -- A separate package exists only for test/demo/support code and adds publish or dependency overhead. -- A feature implements speculative product generality: multi-session/session-load, background job rosters, live registry invalidation, mid-turn steering, tool-owned UI rendering, and similar designs with no product owner. -- An invariant, rollback path, set of expected outputs, or special-case test exists only to protect an unused API. -- Hand-rolled code reimplements what a well-maintained external package or a Node builtin at the engine floor already provides, and the swap would delete the implementation plus its dedicated tests ([dependency policy](../../notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md)). -- The simplified behavior may differ slightly, but the new behavior is still reasonable and easier to explain. +## Search for removable obligations -Thin candidates are not enough for an Agent Note: deleting one typo, running `knip` once, removing an intentionally documented backend/adapter, or flagging "this looks complex" without call-site proof. +Use these questions to guide discovery. Read the relevant section of [historical patterns](references/historical-patterns.md) when a candidate needs calibration; the examples illustrate decisions, not a current deletion inventory. -### Audit invariant companions +- **Does a declared feature have a complete effect path?** Trace producer, transformations, provider or endpoint support, and observable result. A field copied everywhere may still have no setter, no reader, or only implementations that reject it. Search constructors and discriminant emitters as well as callers. +- **Which distinctions change a consumer's action?** Several internal states may require one model-facing status or one usable identifier. Keep internal distinctions that control authorization, residency, durability, or ownership; remove public distinctions that only invite an unusable choice. +- **Could a smaller explicit behavior remove the subsystem?** Fixed intervals, caller-supplied time zones, or reported activation failure can eliminate generalized calendars, persisted defaults, or automatic rollback. Name the capability given up and the machinery deleted. A production caller makes this a behavior decision, not an automatic rejection; implementation must remain within the user's authorization. +- **Can a consumer read the authoritative value when needed?** Look for copied histories, promotion ledgers, invalidation events followed by reads, and feature caches beside a shared projection stream. Derivation or reuse may delete subscriptions, retained data, stale-response handling, and recovery paths together. Establish the required time of observation first. +- **Is composition being mistaken for policy?** Optional service presence, a provider method, or a sibling tool can express availability without deciding deployment behavior. Explicit configuration or a caller-intent operation may remove registries and dependency checks. Preserve distinct operations when their ownership or completion timing differs. +- **What owns the complete maintenance cost?** Trace pass-through configuration, duplicate application trees, demo launchers used by products, resolver-only workspaces, and installation helpers with their own upgrade lifecycle. Compare the entire removed system with the replacement, including residual glue; moving complexity or adding a gate to keep duplicate definitions equal does not remove it. -Treat an invariant companion as useful only when it compares independently produced observations that can diverge. Remove empty installers and checks that merely inspect service presence, plugin metadata, fixed examples, or the result of calling the same mutation they claim to verify. For every omission, remove the export, build entry, invariant-only compiler reference or dependency, and companion-only test, then record the package-specific reason in both package READMEs. Keep a companion when it compares distinct event producers, durable history, or independently mutable data, even if the package also validates inputs synchronously. +## Prove reachability and the trade-off -## Survey Broadly +Start with `rg`, then read the matches. Search exact symbols, property reads and writes, discriminants, event and wire strings, config keys, package names, and both `.method(` and `method(` forms. Tests and declarations can show a contract without proving a shipped producer or consumer. -Use parallel subagents when the user asks for breadth or many candidates. Give each agent a domain and require evidence, not guesses. Useful domains: +Include `packages/*/*/src`, `apps/`, `python/`, runtime scripts, shipped profiles and overlays, package manifests and exports, loader resolution, generated runtime assets, and installed-package consumers. Classify examples and fixtures by their actual entry point rather than directory name. Follow generated catalogs to their consumers: a catalog used by model-written plugin mounts is a dynamic product API, while a documentation-only listing is not a fixed caller. Public third-party extension paths can be intentional despite zero repository call sites. -- Agent loop and session log: turn/step boundaries, steering, abort/cancel, durable events, replay, load/resume. -- ACP automation and human UI APIs: prompt settlement and teardown on the protocol side; transcript rendering and interaction state on the UI side. -- LLM/tools/system prompt: stream/generate APIs, assemblers, registries, tool schema defaults, presentation hooks. -- Bash and tool execution: foreground/background split, job ownership, output spill files, executor methods. -- Packages/examples/scripts/tests: package splits, static inventories, redundant snapshot expected outputs, support packages. +For each candidate, record the current owner, effective producer/consumer path, what disappears, what remains, and the strongest reason to retain it. Distinguish: -If subagents are unavailable, simulate the same breadth yourself. Do not let the first good candidate stop the survey. +- removal of unreachable or unread behavior; +- a narrower public or product behavior with an explicit loss; +- a protected obligation or insufficient evidence. -Start with the largest production-code deltas. A broad simplification audit that stops after obvious unused symbols can miss the files where duplicated lifecycle or defensive machinery carries most of the cost. +Reject a candidate when it breaks a retained obligation, merely relocates the same complexity, or has no meaningful reduction. Read rejection reasons at the level of the proposed change: a stale inventory or a mixed proposal's rejected behavior does not settle every independent item. Refresh the evidence before re-proposing one. Small local improvements belong in actionable `TODO`/`FIXME`/`XXX` comments under the [urgency rules](../../../docs/development.md), not standalone design records. -## Simplify Prose With The Code +## Preserve ownership and failure semantics -Treat comments and documentation as maintained surface area. Apply [dsh-prose-standard](../dsh-prose-standard/SKILL.md) when a survey includes prose. +For copies, freezes, validators, and callback captures, name the value's origin, next owner, and trust boundary. Typed same-process calls ordinarily borrow readonly values; parsers, config loaders, queues, model/tool JSON, durable files, workers, processes, and wire decoders own or validate their inputs. Hostile-getter or callback-replacement tests do not establish a requirement by themselves. A frozen root does not prove its descendants immutable, and equal ids do not prove object identity. -- Delete comments that restate code or explain behavior owned elsewhere; keep required local contracts. -- Keep docs at their owning level; omit implementation details and rare cases unless they change a maintained contract. +For asynchronous machinery, map each promise, flag, cancellation path, disposer, and reservation to its owner and transition. Collapse mechanisms only when they express the same fact. Preserve synchronous publication and rollback, callback containment, first-terminal-outcome arbitration, worker/process ownership, and dispose-to-quiescence where required. Durable start/end markers can record real facts even when transcript renderers ignore them; dropping interrupted work is a capability loss, not redundant-log cleanup. -## Audit Trust And Lifecycle Boundaries +An invariant companion is justified by comparing independently produced observations that can diverge. Checks of service presence, plugin metadata, fixed examples, or the same mutation's return value do not qualify. Removing a companion includes its export, build entry, invariant-only compiler references, dependencies, and tests; record the package-specific omission reason in both READMEs. Keep checks comparing independent event producers, durable history, or mutable data. -For every defensive copy, freeze, validator, and callback capture, name where the value came from and who owns it next. Same-process typed service/plugin calls ordinarily borrow readonly values; parsers, config loaders, queues, model/tool JSON, durable files, workers, processes, and wire decoders own or validate their data. Tests built around hostile getters, fake typed objects, callback replacement, or mutation after a same-process handoff are evidence of a potentially speculative contract, not automatic justification for keeping it. +## Replace infrastructure only for a net reduction -For complex asynchronous code, draw the ownership graph and map each sentinel, readiness promise, cancellation path, disposer, and state flag to a distinct owner or transition. When several mechanisms mirror the same liveness or settlement fact, propose one transaction or lifecycle controller instead. Preserve separate machinery where it protects synchronous publication and rollback, callback containment, first-terminal-outcome arbitration, worker/process ownership, or dispose-to-quiescence. +Follow the [dependency policy](../../notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md). Search existing dependencies and Node builtins at the engine floor before adding a package. Identify the exact implementation and dedicated tests the replacement deletes, its maintenance and transitive cost, and every residual behavior the glue must preserve. -## Hand-Rolled Code Versus A Dependency +Exercise the mismatches most likely to defeat the replacement: protocol truncation, cancellation and teardown, configured bounds, deterministic test clocks, platform packaging, and source versus built entry points. A library that covers framing but requires rebuilding the same framing to enforce a size limit is not a simplification. An accepted edge-behavior change belongs in the proposal and relevant tests. Do not re-prove a dependency's generic specification while omitting tests for the harness-specific shim. -Introducing a dependency is a valid simplification move, not a policy exception: the [dependency policy](../../notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md) owns the bar. When surveying, ask of protocol parsers, framers, retry/backoff loops, glob matchers, diff engines, and similar infrastructure: does a well-maintained npm package or a Node builtin at the repo's engine floor already do this? +## Record, consolidate, and validate -Prove a dependency-swap candidate like any other, plus: +Use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) when prose is in scope. Remove code-restating or remotely owned explanations while preserving local behavior, timing, ownership, and failure obligations. Update affected READMEs, JSDoc, model-visible catalogs, configuration, snapshots, and generated files with their owning change. -- Read the hand-rolled implementation and name the exact surface the package covers; residual semantics the package does not cover count against the swap and stay in the Agent Note. -- Check the package's health honestly (maintenance, adoption, transitive footprint) and prefer builtins when the engine floor has them. -- Check the Agent Note tree first: schemastery, vendored Cordis, the twin adapters, and other recorded seams are settled — a swap that collapses one needs to beat the recorded rationale, not just cite the policy. -- Weigh net deletion: implementation plus dedicated tests plus docs, minus the glue that remains. A wrapper that relocates the same complexity is not a win. +A substantial proposal uses the mandatory note skeleton: `Problem`, `Proposal`, `Alternatives considered`, `Acceptance criteria`, and `Risks`. Include concrete consumer evidence, the removed maintenance cost, the capability given up, and observable acceptance conditions. An implemented decision uses the [implemented format](../../notes/README.md#the-body-skeleton) instead. Update an existing owner when the decision is the same; do not create duplicate notes to preserve candidate counts. -## Prove Or Reject Each Candidate +Every new note requires a scoped supersession check through [dsh-archive-agent-notes](../dsh-archive-agent-notes/SKILL.md). That workflow owns retention, consolidation, triplet deletion, and frozen archive mechanics. A code survey does not imply a repository-wide note audit. Preserve partial supersessions and current durable, wire, compatibility, or rejected-alternative obligations. -For every symbol or behavior, classify consumers before writing: +When folding another branch, compare its independent diff against the target base, port only supported non-overlapping proposals, and consolidate overlapping rationale. Closing another PR requires authorization or clear ownership of that housekeeping. -- Production corpus: `packages/*/src`, `examples/*/src`, `examples/**/*.yml`, runtime scripts, and loader/config paths. -- Non-production corpus: tests, README/docs, Agent Notes, snapshots, generated expected outputs, and comments. -- Ambiguous corpus: examples and scripts that may be product smoke paths. Inspect usage before classifying. - -Use `rg` first. Good searches include the exact symbol, event name, package name, config key, method name with both `.name(` and `name(`, and any wire strings. Then read the call sites, public interfaces, dynamic event names, tests, docs, and Cordis loader paths. - -Reject or downgrade a candidate when: - -- A production caller exists and the simplification would be a feature decision rather than a cleanup. -- The API is explicitly justified by an implemented Agent Note or a hard-won defensive pattern, and the new evidence does not beat that reason. -- The removal would force unrelated churn without actually reducing the public API or required behavior. -- The idea is correct but tiny. Add a targeted TODO/FIXME/XXX instead, using the urgency semantics in [docs/development.md](../../../docs/development.md). - -## Coalesce Superseded Agent Notes - -Audit the Agent Note tree when the user asks to reduce or coalesce it, or when the simplification being implemented makes an owning note obsolete. Do not expand every code-simplification survey into a repository-wide note audit. - -Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Implemented notes that only describe small UI adjustments or purely mechanical changes are deleted directly; other completed notes with historical decision value but little future guidance move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code. - -Follow the deletion rule in the [Agent Note rules](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: - -1. Identify the current owner from shipped code, configuration, generated catalogs, package docs, newer Agent Notes, and inbound links; dates and titles are discovery hints, not proof. -2. Classify the old note as fully or partially superseded. Any surviving behavior, current contract, durable format, compatibility obligation, or independently current rejected alternative makes it partial. Rationale that can be transferred to the current owner does not by itself make supersession partial. -3. For full supersession, move every unique rationale, alternative, consequence, shipped verification evidence, and named coverage gap into the current owner. An inventory that only describes deleted implementation mechanics is not one of those decision facts. -4. Repair every inbound link, then delete the English note, Chinese counterpart, and consistency record together. -5. Search exact filenames, symbols, config keys, event names, and wire strings after the edit. Keep partial supersessions cross-linked and current. - -An added-then-removed feature is a common full-supersession case. Let the removal note own the history only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that enforce absence may remain. Preserve why the feature originally existed, why that motivation no longer justified it, alternatives to full removal, the capability given up, conditions for reintroduction, and evidence that removal is complete. Old tests and implementation mechanics that verified only the deleted behavior are not current verification evidence. - -Reject consolidation when the removal is only one transport, default, implementation, or presentation of a feature; when persisted data or compatibility handling survives; or when the removal note does not yet carry enough rationale to prevent accidental reintroduction. A current negative design decision may legitimately need its own note even though the removed implementation is gone. - -## Write The Agent Note - -Create one file per durable proposal under `.agents/notes///yyyy-mm-dd-topic.md`, following the lifecycle and classification rules in `.agents/notes/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links. - -Prefer this structure, adjusting when the idea needs it: - -- `# Agent Note: ` -- `Status: proposed` -- `## Problem`: name the current API, cite the relevant files, and state the consumer evidence. Separate production callers from tests/docs. -- `## Proposal`: say exactly what to remove, fold, demote, or rehome. Include tests, docs, READMEs, JSDoc, event-taxonomy, snapshot, and generated-file cleanup when relevant. -- `## Why not keep it?` or `## What we give up`: make the strongest counterargument legible. -- `## Acceptance criteria`: observable end state and gates. -- `## Risks`: public API changes, behavior changes, future product wants, and why the tradeoff is still reasonable. - -Be concrete enough that an implementing PR can follow the trail. Avoid vague "simplify this package" Agent Notes. When a proposal overlaps an existing Agent Note, consolidate the useful details into the existing one rather than creating a duplicate. - -## Inline TODO Notes - -Use inline TODO/FIXME/XXX only for small, local cleanups that are clearly useful but not durable design decisions. Keep them short and actionable: - -- Name the smell with a stable tag, e.g. `TODO(double-default)` or `XXX(unused-default)`. -- Explain why it is safe to revisit and what action would simplify it. -- Do not add TODOs for speculative complaints or for behavior that needs an Agent Note-level decision. - -## When Folding Another PR Or Branch - -Diff the sibling branch against `origin/master`, not against the current PR branch, so you see its independent contribution. For each item: - -- Port non-overlapping Agent Notes or TODOs that meet the quality bar. -- Consolidate overlapping material into the existing Agent Note that owns the topic. -- Do not port duplicate or lower-confidence proposals just to preserve the count. -- Update the PR body so reviewers see the true candidate count and scope. -- Close the duplicate PR only when the user asked you to, or when you clearly own that housekeeping. - -## Validation And PR Hygiene - -For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`. For code comments or skill changes, also run the relevant validator when one exists. Select any other evidence from the outgoing diff; the pre-push hook contributes typecheck only. - -When opening or updating a PR, summarize: - -- How many Agent Notes and inline notes were added, consolidated, retained as partial supersessions, or deleted. -- The main areas surveyed. -- What was intentionally excluded. -- Which checks passed. - -For each consolidation group, name the old and current owners, state the evidence for full supersession, and explain why deletion is safe. If an added-then-removed scan finds no qualifying note, report that result and the representative partial cases retained. - -Use a draft PR while the survey is still expanding; mark ready only when the candidate set, review responses, and validation are settled. +For docs-only Agent Note work, run `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; for skill or comment edits, include the applicable validator. Select other checks through [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md). Report the surveyed areas, supported candidates, meaningful rejections or deferrals, notes added/consolidated/retained/removed, and commands actually run. Use a draft PR while the survey is expanding; do not describe an unverified search as exhaustive. diff --git a/.agents/skills/dsh-find-simplifications/references/historical-patterns.md b/.agents/skills/dsh-find-simplifications/references/historical-patterns.md new file mode 100644 index 0000000000..f5f91342e4 --- /dev/null +++ b/.agents/skills/dsh-find-simplifications/references/historical-patterns.md @@ -0,0 +1,45 @@ +# Historical simplification patterns + +Use these examples to calibrate a candidate whose value is not established by an unused-symbol search. They describe recorded decisions, not current removal opportunities. Archived sources are frozen snapshots: read the cited note itself, never follow or repair its outbound links. Verify current owners and released-data obligations before applying a pattern. + +## Require a complete effect path + +A declaration, serializer branch, and test can form convincing-looking support without an effective feature. Trace both ends: something must produce the value, and an implementation must honor it. The [producerless-variants decision](../../../notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) removed reserved union members and cache hints without emitters. The [inert-request-knobs decision](../../../notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md) distinguished knobs rejected by every adapter from working request fields available to hooks. Deletion included declarations, forwarding, rejection-only tests, and generated documentation. + +Zero fixed callers is weaker evidence than unreachability. The [skill-registration rejection](../../../notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md) preserves a deliberate third-party registration path. For catalogued services, inspect generic plugin invocation and returned values before calling them dead. A working extension point and a field no provider can honor require different verdicts. + +## Reduce distinctions where consumers decide + +Map every public variant to the consumer action it enables. The [model agent-availability decision](../../../notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md) gives models one usable Team target and projects multiple residency states to the same availability, while internal routing retains Session identity and residency. The [capability-neutral sandbox context](../../../notes/archived/simplification/2026-07-31-capability-neutral-sandbox-policy-context.md) replaces two approximate capability registries with truthful conditional policy wording; exact tool availability remains in tool schemas. The gain includes registration methods, disposal state, invalidation, and combination tests. + +Do not erase distinct durable facts merely because one renderer ignores them. The [durable step-boundary rejection](../../../notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md) retains explicit start and completion facts needed for empty failed steps, crash repair, and inspection. Simplify the projection only when the underlying distinction remains necessary. + +## Trade general behavior for a smaller explicit promise + +A used feature can still be an expensive way to satisfy the required outcome. The [fixed-rate Schedule decision](../../../notes/archived/simplification/2026-08-09-bounded-fixed-rate-schedule.md) gives up calendar recurrence and missed-occurrence replay, removing a parser, evaluator, cross-record gate, durable variants, and backlog states. The [non-transactional Loader decision](../../../notes/implemented/simplification/2026-09-09-nontransactional-loader.md) gives up automatic plugin rollback while keeping failure reporting and consumer-owned activation checks. Moving generic rollback to another package would retain its cost. + +State the loss as clearly as the deletion. The [interrupted-turn truncation rejection](../../../notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md) rejects a simpler recovery algorithm because an unfinished turn can contain substantial real work. Convenience, smaller unions, or fewer tests alone do not justify losing required data or behavior. A survey can propose the trade-off without implying permission to implement it. + +## Make assumptions explicit at their consuming operation + +Look for a local default that has acquired shared storage, synchronization, or confirmation machinery. The [explicit Schedule time-zone decision](../../../notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md) keeps browser facts on the originating request and requires an explicit zone in the scheduling operation, removing Session defaults and cross-tab conflict state. The [injected-content decision](../../../notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md) leaves framing with the caller and removes envelope controls threaded through unrelated layers. Both reduce the number of owners that must interpret one assumption. + +Do not move a requirement past the place that must enforce it. The [Host-only Remote input-validation decision](../../../notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.md) removes duplicate Client schema execution but retains Host validation before lookup and business invocation, plus local arity and Context binding checks. Its documented cost is later malformed-input failure and the loss of Client parsing as accidental redaction. + +## Derive at use or reuse an existing delivery owner + +For each cache or event subscription, ask when the consumer needs the value and whether an authoritative source already retains it. [Feedback telemetry](../../../notes/archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md) reads the canonical Session prefix at feedback time, deleting a continuously copied pre-feedback buffer and its listeners. [Workspace recency](../../../notes/implemented/simplification/2026-09-10-derived-workspace-recency.md) sorts current summaries instead of persisting promotion history; manual ordering remains independent user state. [Web subagent projections](../../../notes/implemented/simplification/2026-09-08-web-subagent-catalog-projections.md) reuse a shared stream and projection store, removing feature notifications, repeated reads, and a second membership cache. + +The timing promise determines whether this works. Feedback-time redaction differs from capture-time redaction; manual placement cannot be derived from activity timestamps; an initial historical read may remain necessary when a stream baseline is absent. Name such residual obligations instead of declaring all caches redundant. + +## Remove accidental composition owners + +Inspect actual launch and resolution paths before trusting names such as demo, support, or example. The [shared-base-config decision](../../../notes/archived/simplification/2026-07-29-shared-base-config-overlays.md) removes duplicate compositions and pass-through settings; actual Loader behavior determines overlay placement. The [owner-local profiles and guides decision](../../../notes/archived/simplification/2026-08-24-owner-local-profile-tests-and-guides.md) removes a resolver-only workspace by assigning tests, guides, and shipped assets to their owners. The [merged subagent service decision](../../../notes/archived/simplification/2026-07-26-merge-subagent-control-service.md) uses explicit deployment policy instead of inferring it from provider or sibling-tool presence, while preserving optional dependencies internally. + +A smaller package count is not sufficient. Separate caller intents may have different ownership and completion timing; a retained Service Definition may serve independent providers. Prove the replacement's real composition and dependency behavior, and account for all remaining configuration and lifecycle glue. + +## Use rejected experiments to test parity claims + +The [SSE parser replacement](../../../notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md) deletes generic framing and its conformance tests while retaining the provider-specific termination shim and documenting stricter truncated-tail behavior. In contrast, the [timer-promises rejection](../../../notes/rejected/simplification/2026-07-26-builtin-timer-promises-for-hand-rolled-sleeps.md) records that a nominally equivalent builtin defeated deterministic fake-clock tests for a small deletion. The [dependency-swap audit](../../../notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md) distinguishes replaceable generic code from configured bounds, security rules, lifecycle ownership, and deployment behavior a library does not implement. + +A past rejection is a scoped falsifier, not a permanent package blacklist. Revisit it only with new evidence addressing the failed premise, and measure the implementation, glue, tests, dependencies, and supported behavior together. diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 75852bf6b4..72a3db40db 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -20,6 +20,7 @@ interface SessionSummary { readonly origin?: 'subagent' readonly cwd?: string readonly projections?: { + readonly kind: 'cached' | 'sequenced' readonly asOfSeq: number readonly values: Readonly> } @@ -260,7 +261,9 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass running: false, blank: true, cwd, - projections: structuredClone(blankSessionProjections), + // The created Session is live on this fixture Host: its list block is + // sequenced, like the block the real live registry would serve. + projections: { kind: 'sequenced', ...structuredClone(blankSessionProjections) }, } sessions.push(summary) records.set(sessionId, []) diff --git a/apps/web/tests/client-plugin-live.e2e.ts b/apps/web/tests/client-plugin-live.e2e.ts index 3b321032de..452a7c36ce 100644 --- a/apps/web/tests/client-plugin-live.e2e.ts +++ b/apps/web/tests/client-plugin-live.e2e.ts @@ -5,11 +5,17 @@ import { tmpdir } from 'node:os' import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { chromium, type Page } from 'playwright' import { expect, it, onTestFailed, onTestFinished } from 'vitest' -import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts' -import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, + seedSession, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts' const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-live-client', import.meta.url)) const EXPECTED = fileURLToPath(new URL('./expected/client-plugin-live', import.meta.url)) +const SESSION_ACTION_EXPECTED = join(EXPECTED, 'session-actions.expected.md') +const SESSION_SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const SESSION_TITLE = 'Session action extension' async function openInventory(page: Page, url: string) { await page.goto(url, { waitUntil: 'load' }) @@ -20,6 +26,68 @@ async function openInventory(page: Page, url: string) { return dialog } +it('places dynamic Session menu rows by order among the shipped ones and removes them with their fiber', async () => { + const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] }) + onTestFinished(() => scaffold.close()) + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + const sessionId = await seedSession(scaffold, await readFile(SESSION_SEED, 'utf8'), 'session-menu-actions-web-e2e') + await workspace.attachSession(sessionId) + await scaffold.ctx.sessionController.rename({ sessionId, title: SESSION_TITLE }) + const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' }) + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const console = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-session-menu-actions')) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const row = page.getByRole('treeitem').filter({ has: page.getByText(SESSION_TITLE, { exact: true }) }) + await row.waitFor({ timeout: 20_000 }) + const trigger = row.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` }) + + await row.hover() + await trigger.focus() + await trigger.click() + const menu = page.getByRole('menu') + await menu.waitFor() + expect(await menu.getByRole('menuitem').allTextContents()).toEqual([ + 'Pin session', 'Rename', 'Fork session', 'Archive session', 'Export session', 'Copy session ID', + ]) + expect(await menu.getByRole('separator').count()).toBe(1) + await compareOrRefreshGolden( + SESSION_ACTION_EXPECTED, + await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), + webSnapshotMode(), + ) + + await menu.getByRole('menuitem', { name: 'Export session' }).click() + await expect.poll(() => menu.count()).toBe(0) + expect(await page.evaluate(() => ({ + action: document.documentElement.dataset.sessionAction, + id: document.documentElement.dataset.sessionActionId, + title: document.documentElement.dataset.sessionActionTitle, + }))).toEqual({ action: 'fixture.export-session', id: sessionId, title: SESSION_TITLE }) + + scaffold.ctx.loader.remove(entryId) + await expect.poll(() => page.evaluate( + () => document.documentElement.dataset.liveDisposals, + )).toBe('1') + await row.hover() + await trigger.click() + await menu.waitFor() + expect(await menu.getByRole('menuitem').allTextContents()).toEqual([ + 'Pin session', 'Rename', 'Fork session', 'Archive session', + ]) + expect(await menu.getByRole('separator').count()).toBe(0) + expect(console.pageErrors).toEqual([]) + await assertFixtureInventory(EXPECTED, [ + 'bootstrap-rebuild.expected.md', 'enabled.expected.md', 'recovered.expected.md', + 'session-actions.expected.md', + ]) + } finally { + await browser.close() + } +}, 120_000) + it('synchronizes two pages, disposes effects and restores an offline page from the latest graph without navigation', async () => { const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')], diff --git a/apps/web/tests/expected/client-plugin-live/session-actions.expected.md b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md new file mode 100644 index 0000000000..f3c45cace9 --- /dev/null +++ b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md @@ -0,0 +1,8 @@ +- menu: + - menuitem "Pin session" + - menuitem "Rename" + - menuitem "Fork session" + - menuitem "Archive session" + - separator + - menuitem "Export session" + - menuitem "Copy session ID" diff --git a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js index d870a5e52a..4d2e32c6ab 100644 --- a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js +++ b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js @@ -3,6 +3,7 @@ window.__ModuleLoader__.load({ id: '@fixture/live-client', factory(require) { const React = require('react') + const { MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives') const style = document.createElement('style') style.dataset.plugin = '@fixture/live-client' style.textContent = '[data-live-client] { color: rgb(12, 34, 56); position: absolute; bottom: 20px; right: 20px; }' @@ -13,8 +14,14 @@ window.__ModuleLoader__.load({ const counters = document.documentElement.dataset counters.liveMounts = String(Number(counters.liveMounts ?? 0) + 1) ctx.effect(() => ctx.locale.register('fixtureLive', { - zh: { active: '动态插件已启用', configSummary: '示例配置项', configForm: '动态插件配置', configField: '问候语', configSave: '保存' }, - en: { active: 'Live plugin enabled', configSummary: 'An example setting', configForm: 'Live plugin configuration', configField: 'Greeting', configSave: 'Save' }, + zh: { + active: '动态插件已启用', configSummary: '示例配置项', configForm: '动态插件配置', configField: '问候语', configSave: '保存', + exportSession: '导出会话', copySessionId: '复制会话 ID', + }, + en: { + active: 'Live plugin enabled', configSummary: 'An example setting', configForm: 'Live plugin configuration', configField: 'Greeting', configSave: 'Save', + exportSession: 'Export session', copySessionId: 'Copy session ID', + }, })) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'fixture-live-client', locale: 'fixtureLive', @@ -34,6 +41,35 @@ window.__ModuleLoader__.load({ }, React.createElement('label', null, t('configField'), React.createElement('input', { name: 'greeting', defaultValue: 'hello' })), React.createElement('button', { type: 'submit' }, t('configSave'))))) + const actionInjected = () => ({ + selectAction(action, sessionId, displayTitle) { + counters.sessionAction = action + counters.sessionActionId = sessionId + counters.sessionActionTitle = displayTitle + }, + }) + const registerSessionAction = (id, order, label, separatorBefore) => ctx.slots.register({ + name: 'sidebar.workspaces.session.menu.item', id, order, locale: 'fixtureLive', inject: actionInjected, + }, ({ sessionId, displayTitle, useMenuOpenState, selectAction, t }) => { + const [, setMenuOpen] = useMenuOpenState() + return React.createElement( + MenuItemButton, + { + separatorBefore, + onSelect: () => { + setMenuOpen(false) + selectAction(id, sessionId, displayTitle) + }, + }, + t(label), + ) + }) + ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () { + // The shipped rows end at archive (400); these follow as one group, + // opened by the export row's hairline. + yield registerSessionAction('fixture.export-session', 500, 'exportSession', true) + yield registerSessionAction('fixture.copy-session-id', 600, 'copySessionId', false) + }) ctx.effect(() => { const ping = () => { counters.liveHits = String(Number(counters.liveHits ?? 0) + 1) } window.addEventListener('dsh-fixture-ping', ping) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 2530ec0497..fd4a0b2e9c 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -396,9 +396,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => { // The child row is published before its inherited title rename settles; // wait for that second RPC projection before freezing the ARIA tree. await expect.poll( - () => page.locator('[role="treeitem"][aria-selected="true"]').textContent(), + () => page.locator('[role="treeitem"]').allTextContents(), { timeout: 10_000 }, - ).toContain('Use the read tool twice (2)') + ).toEqual(expect.arrayContaining([expect.stringContaining('Use the read tool twice (2)')])) + expect(await sourceRow.textContent()).toContain('Use the read tool twice (1)') const tree = await captureStableAria( page, '[role="tree"][aria-label="Sessions"]', diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index b9b436b27a..3ace6ed5df 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -409,6 +409,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await openTerminal(page) const firstTab = await page.locator('[data-dockkit-tab][aria-selected="true"]').getAttribute('data-dockkit-tab') const firstProcess = processIdentity(0) + // Busy activity isolates layout recovery from unattended idle reclamation. + vi.spyOn(handles[0]!, 'inspectActivity').mockResolvedValue({ state: 'busy', revision: 1 }) await command(page, "printf 'WINDOW_A\\n'") await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A') await openTerminal(second) @@ -433,11 +435,11 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await expect.poll(() => bindings()).toHaveLength(1) expect(saved).toContain((await bindings())[0]) await page.reload({ waitUntil: 'load' }) - await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A') - await command(page, "printf 'WINDOW_A_RECONNECTED\\n'") - await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A_RECONNECTED') - expect(handles).toHaveLength(2) + await page.getByText('Ready for terminal input.').waitFor() + // The second window saved an empty layout; disabled recovery does not recreate the first window's tab. + expect(await page.locator('[data-sidebar-terminal]:visible').count()).toBe(0) expect(alive(firstProcess)).toBe(true) + expect(handles).toHaveLength(2) expect(tripwire.pageErrors).toEqual([]) expect(secondErrors.pageErrors).toEqual([]) }) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index d5e0de109e..a4c875b156 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -86,7 +86,7 @@ async function waitForCacheRow( header: SessionHeader, ): Promise { const deadline = Date.now() + 10_000 - while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0)) === undefined) { + while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined) { if (Date.now() >= deadline) throw new Error(`cache row for "${header.id}" did not land`) await new Promise(resolve => setTimeout(resolve, 10)) } diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3037c7f95d..2b299efe57 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: e7b759ad04ba1c8222359f935e3834d4fe81295e -config-catalog.zh.md: 9d39b4821cd6fb223db0a028de3376a6e3804b4b +config-catalog.md: df0cadde3f488c69278dcee3bb29252f0279ddc4 +config-catalog.zh.md: 6cb3680db988fe8f1a76cbed8986fff5d8ca715e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e7b759ad04..df0cadde3f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2232,7 +2232,7 @@ export interface Config { } ``` -Source: [`packages/session/session-projection-cache/src/index.ts:63`](../packages/session/session-projection-cache/src/index.ts) +Source: [`packages/session/session-projection-cache/src/index.ts:75`](../packages/session/session-projection-cache/src/index.ts) @@ -3058,7 +3058,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -3239,7 +3239,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts) @@ -3372,7 +3372,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:43`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9d39b4821c..6cb3680db9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3060,7 +3060,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts) +来源:[`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -3241,7 +3241,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts) @@ -3374,7 +3374,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.zh.md) -来源:[`packages/subagent/tool-subagent/src/index.ts:43`](../packages/subagent/tool-subagent/src/index.ts) +来源:[`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0e97efd9a4..74826abca6 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 9f9d2e2172ec0af51306b24060cd0e12d1b6efee -event-producer-consumer.zh.md: 4e46a064d1471d1e8802afc04a86772515fe3b09 +event-producer-consumer.md: 3fbbb8f62adbb1e3e77f629cdc89c6c3c85a524e +event-producer-consumer.zh.md: 1032f9c0df45db33b36554876b679f368d58841b diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9f9d2e2172..3fbbb8f62a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 4e46a064d1..1032f9c0df 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 280362abd1..bbd8e24fca 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 67f282f1f4c0df52b02d1f85f50ea3eb904934e3 -persistence-catalog.zh.md: 17c8b8074a46f47c0a744cad731cccdf1c465eac +persistence-catalog.md: f42ec2873da8eb6a48a71a012dd53acb810336bd +persistence-catalog.zh.md: 9df37e6570f1a2871bb959e670cdcca9cc61317c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 67f282f1f4..f42ec2873d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -4074,7 +4074,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:86`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5312,7 +5312,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7219,7 +7219,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 17c8b8074a..9df37e6570 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -4076,7 +4076,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:86`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5314,7 +5314,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7221,7 +7221,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index bfa53267f8..8f1d5db204 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44192,7 +44192,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:380", + "packages/api/session-controller/src/types.ts:394", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -57718,7 +57718,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:385" + "packages/api/session-controller/src/types.ts:399" ] }, { @@ -86472,7 +86472,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:86" + "packages/api/session-controller/src/types.ts:100" ] }, { diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 808a5bb76f..cc738c4fcb 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: cb3ac37d593a2b5aae6725854c31a3a39ae3152b -session-projection.zh.md: 5051dab59316840267d16d201a17717742565059 +session-projection.md: 8580b94c4d7dd36f942ff10d77e13d2dc70b7e8b +session-projection.zh.md: b98d348a36c51aab973bc3397ddbf744860245a4 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index cb3ac37d59..8580b94c4d 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -122,20 +122,24 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark so - * a client value store can seed under its higher-seq-wins rule — as stale - * as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline) supersede these values whenever a - * session is actually opened. + * rows (version-matching keys only) of the record bound to the caller's + * lifecycle. The header is the only identity witness a listing holds, so + * this face matches the lifecycle identity (`formatVersion`, `createdAt`, + * `cwd`, `isSeeded`) and not the inherited cut: within one format + * generation the cut is fixed at fork time, so it distinguishes no + * lifecycle the other fields do not, and a viewed value never seeds a fold. + * The view is as stale as the last durable checkpoint but never wrong and + * never from an unrelated log. Its `asOfSeq` is the lowest watermark among + * the served rows: the stored record's own position, which the header + * cannot relate to the log the caller later opens. The Session list + * therefore labels the block as cached, and the client lets every value the + * connected Session produces supersede it whatever this number says. * @param meta - the listed session's header (identity witness; no log read). - * @param inheritedEventCount - exact inherited prefix length that completes - * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. - * @returns the cut (`asOfSeq` = lowest served-row watermark), or - * `undefined` when no usable row exists for this lifecycle. + * @returns the viewed block, or `undefined` when no usable row exists for + * this lifecycle at the current Session format. */ -cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined /** * Read only a predecessor checkpoint's title as a zero-I/O listing hint. @@ -146,15 +150,13 @@ cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys * fact from this Session. The registry still requires the current title * projection's row version and schema. No other predecessor projection is * exposed: format normalization can change their current meaning, and the - * strict {@link cachedSnapshot} / hydration paths continue to reject them. + * {@link cachedSnapshot} / hydration paths continue to reject them. * @param meta - authoritative listed Session header. - * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. - * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined` - * when the record is current, newer, unrelated, missing, or incompatible - * with the title unit. The sentinel avoids reusing a sequence that a - * cardinality-changing Session migration may have remapped. + * @returns a title-only block at the stored title row's watermark, or + * `undefined` when the record is current, newer, unrelated, missing, or + * incompatible with the title unit. */ -cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined +cachedPredecessorTitle(meta: SessionHeader): ProjectionSnapshot | undefined /** * Hydrate projection cells for an already-prepared Session without another diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 5051dab593..b98d348a36 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -122,20 +122,24 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark so - * a client value store can seed under its higher-seq-wins rule — as stale - * as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline) supersede these values whenever a - * session is actually opened. + * rows (version-matching keys only) of the record bound to the caller's + * lifecycle. The header is the only identity witness a listing holds, so + * this face matches the lifecycle identity (`formatVersion`, `createdAt`, + * `cwd`, `isSeeded`) and not the inherited cut: within one format + * generation the cut is fixed at fork time, so it distinguishes no + * lifecycle the other fields do not, and a viewed value never seeds a fold. + * The view is as stale as the last durable checkpoint but never wrong and + * never from an unrelated log. Its `asOfSeq` is the lowest watermark among + * the served rows: the stored record's own position, which the header + * cannot relate to the log the caller later opens. The Session list + * therefore labels the block as cached, and the client lets every value the + * connected Session produces supersede it whatever this number says. * @param meta - the listed session's header (identity witness; no log read). - * @param inheritedEventCount - exact inherited prefix length that completes - * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. - * @returns the cut (`asOfSeq` = lowest served-row watermark), or - * `undefined` when no usable row exists for this lifecycle. + * @returns the viewed block, or `undefined` when no usable row exists for + * this lifecycle at the current Session format. */ -cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined /** * Read only a predecessor checkpoint's title as a zero-I/O listing hint. @@ -146,15 +150,13 @@ cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys * fact from this Session. The registry still requires the current title * projection's row version and schema. No other predecessor projection is * exposed: format normalization can change their current meaning, and the - * strict {@link cachedSnapshot} / hydration paths continue to reject them. + * {@link cachedSnapshot} / hydration paths continue to reject them. * @param meta - authoritative listed Session header. - * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. - * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined` - * when the record is current, newer, unrelated, missing, or incompatible - * with the title unit. The sentinel avoids reusing a sequence that a - * cardinality-changing Session migration may have remapped. + * @returns a title-only block at the stored title row's watermark, or + * `undefined` when the record is current, newer, unrelated, missing, or + * incompatible with the title unit. */ -cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined +cachedPredecessorTitle(meta: SessionHeader): ProjectionSnapshot | undefined /** * Hydrate projection cells for an already-prepared Session without another diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index cd5d7d24e1..50d7a12706 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: c441557092d0273c7beebd222ed7ca329b02c0df -slots.zh.md: 48aafad0042e06f7a6945be4768249dfa4b5aab1 +slots.md: 1e820fd880163d565a72733372fa4f2cc70a62b5 +slots.zh.md: c5bec8e752b9d9e1922f608449ca3249d0477062 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index c441557092..1e820fd880 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -116,7 +116,9 @@ root │ ├─ sidebar.panellist │ ├─ sidebar.footer.action │ ├─ sidebar.workspaces -│ │ └─ sidebar.workspaces.directoryFlow +│ │ ├─ sidebar.workspaces.directoryFlow +│ │ ├─ sidebar.workspaces.session.menu.item +│ │ └─ sidebar.workspaces.session.row.action │ └─ sidebar.settings │ ├─ settings.trigger │ ├─ settings.header diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 48aafad004..c5bec8e752 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -116,7 +116,9 @@ root │ ├─ sidebar.panellist │ ├─ sidebar.footer.action │ ├─ sidebar.workspaces -│ │ └─ sidebar.workspaces.directoryFlow +│ │ ├─ sidebar.workspaces.directoryFlow +│ │ ├─ sidebar.workspaces.session.menu.item +│ │ └─ sidebar.workspaces.session.row.action │ └─ sidebar.settings │ ├─ settings.trigger │ ├─ settings.header diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 8ceb4a8da4..f0c39d8dbb 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: 228ed88ed20b9f9581496468e47079afd1542e4f -README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a +README.md: c9f63d462c6e291abda7312c66c8fffcd7109c57 +README.zh.md: f6d03b45b6bf9d3e809e70154adacd59ce341721 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 228ed88ed2..c9f63d462c 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -27,7 +27,7 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. +The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering. @@ -39,7 +39,7 @@ Client list refreshes retain unchanged row objects and reuse the items array whe Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer list, history, or live values. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. @@ -54,12 +54,14 @@ A resume blocked by an existing write handle returns `session/writer-held` with Queue edits replace pending content with non-empty text only. +Attachment authorization reads declared content fields of built-in Session events and completed assistant stream blocks, including flat V4 tool-role messages. Unknown event payloads and unrelated fields cannot authorize attachment reads. + ## Client references `sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures. -References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. +References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. ## Session media references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index edebf8bdda..f6d03b45b6 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 历史页与 follow opening 快照为每个持久 Session 事件携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑流内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;控制器不解析工具定义、不运行展示转换器,也不附加 UI 数据。 -Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 +Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。 @@ -39,7 +39,7 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 -Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 list、history 或 live 值。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 +Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 @@ -54,12 +54,14 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 队列编辑仅允许用非空文本替换待处理内容。 +附件授权读取内置 Session 事件声明的内容字段与已完成的 assistant 流块,包括扁平的 V4 tool 角色消息。未知事件载荷与无关字段不能授权附件读取。 + ## Client 引用 `sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。 -引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 +引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 ## 会话媒体引用 diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index d42310e982..120e4a8f13 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -7,11 +7,14 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { SessionControlBaseline, SessionControlFrame, + SessionProjectionHints, + SessionRenameValue, SessionSummary, SessionJob as JobView, } from '../../types.ts' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import { assertNever } from '@deepseek-ai/dsh-util-values' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' @@ -375,18 +378,12 @@ export class SessionManager { session.handleBlank(s.blank) session.handleRunning(s.running) } - // Seed each row's projection baseline into the per-session value - // store (cold titles surface without opening the session). Per-key - // apply, not seed(): the list block is a partial baseline — the - // cold cache serves only version-matching keys — so an absent key - // must not clear; higher-seq-wins still keeps a stale list block - // from overwriting a newer push frame or tail baseline. + // Land each row's projection block in the per-session value store + // (cold titles surface without opening the session). A list block is + // partial — only the keys its source holds — so an absent key never + // clears; how the present keys merge depends on the block's kind. for (const s of result.value.items) { - const block = s.projections - if (block === undefined) continue - const store = this.projectionStore(s.sessionId) - const values = block.values as Record - for (const key of Object.keys(values)) store.apply(key, values[key], sessionSeqCursor(block.asOfSeq)) + if (s.projections !== undefined) this.applyListBlock(s.sessionId, s.projections) } } else { this.listState = 'error' @@ -502,6 +499,20 @@ export class SessionManager { return result } + /** + * Rename a Session and update its title projection without opening its history. + * @param sessionId - Session to rename. + * @param title - raw title text for Host normalization. + * @returns the accepted title and event position, or the Remote failure. + */ + async rename(sessionId: SessionId, title: string): Promise> { + const result = await this.remote.session.rename({ sessionId, title }) + if (result.ok) { + this.projectionStore(sessionId).apply('title', result.value.title, SessionSeq(result.value.seq)) + } + return result + } + /** * Merge a Host summary, replacing live state and filling missing metadata. * Local create/fork placeholders only fill metadata on an existing row. @@ -589,12 +600,30 @@ export class SessionManager { handleSessionAdded(summary: SessionSummary): void { this.mergeSummary(summary) this.sessions.get(summary.sessionId)?.handleBlank(summary.blank) - const projections = summary.projections - if (projections !== undefined) { - const store = this.projectionStore(summary.sessionId) - for (const [key, value] of Object.entries(projections.values)) { - store.apply(key, value, sessionSeqCursor(projections.asOfSeq)) + if (summary.projections !== undefined) this.applyListBlock(summary.sessionId, summary.projections) + } + + /** + * Merge one list-surface projection block by the sequence space it declares. + * A `sequenced` block came from the Host's live registry for an attached + * Session, so each key lands under higher-seq-wins against that Session's + * baselines and frames. A `cached` block was viewed from the persisted + * checkpoint by a header-only listing: its watermark is not comparable with + * this connection's seqs, so it only fills keys no sequenced row holds. + */ + private applyListBlock(sessionId: SessionId, block: SessionProjectionHints): void { + const store = this.projectionStore(sessionId) + switch (block.kind) { + case 'sequenced': { + const seq = sessionSeqCursor(block.asOfSeq) + for (const [key, value] of Object.entries(block.values)) store.apply(key, value, seq) + return } + case 'cached': + store.applyCached(block.values) + return + default: + assertNever(block.kind, 'session list projection block kind') } } diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index a53e4e73ee..faafa4404f 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -1,10 +1,14 @@ /** * Generic per-session projection value store (push model; see the * session-projection subsystem page, docs/subsystems/session-projection.md): - * the host is the only computation site; the client holds finished - * whole values per key — `key → { value, seq }` — seeded by a follow opening - * baseline and updated by Session Controller `projection` frames, - * under the single rule **higher seq wins**. No client-side domain folding + * the host is the only computation site; the client holds finished whole + * values per key in one of two row kinds. A `sequenced` row + * (`{ value, seq }`) comes from the connected Host — a follow opening + * baseline, a Session Controller `projection` frame, a list block the Host + * computed for an attached Session — and merges under **higher seq wins** + * against other sequenced rows. A `cached` row (`{ value }`) comes from the + * session list's view of the persisted checkpoint, carries no comparable + * seq, and yields to every sequenced write. No client-side domain folding * exists: a domain ships projection support with zero client code. Per-key * bare observable faces feed `useProjection` (ui-renderer binds them). */ @@ -52,11 +56,18 @@ export interface ProjectionsBaseline { values: Readonly> } -/** One key's row: the latest finished value and the seq it is consistent with. */ -interface Row { - value: unknown - seq: SessionSeqCursor -} +/** + * One key's row. A `sequenced` row was computed by the Host for this Session + * within the current connection generation and carries the seq it is + * consistent with; seqs of sequenced rows are comparable with each other. A + * `cached` row was viewed from the persisted projection checkpoint by a + * header-only listing without a Session: its origin cannot vouch that its + * stored seq is comparable with the log the client later opens, so it carries + * none and yields to every sequenced write. + */ +type Row = + | { kind: 'cached'; value: unknown } + | { kind: 'sequenced'; value: unknown; seq: SessionSeqCursor } /** Per-key notification channel: the bare face plus its batching notifier. */ interface Channel { @@ -66,10 +77,14 @@ interface Channel { /** * One session's projection values. Framework semantics, uniform across every - * key: a baseline seeds rows at its cut, a push frame updates one row, and in - * both paths a lower-or-equal seq within the Host generation loses. A replayed - * frame cannot regress a value; a stale baseline cannot overwrite a newer - * frame. A key the store has never seen reads `undefined` (capability absent). Faces are identity-stable + * key. Sequenced writes (a baseline seeds rows at its cut, a push frame + * updates one row) compare seqs among themselves: a lower-or-equal seq within + * the Host generation loses, so a replayed frame cannot regress a value and a + * stale baseline cannot overwrite a newer frame. Cached writes (the session + * list's zero-I/O block) only fill keys no sequenced row holds, and a baseline + * discards every cached row before it seeds, regardless of seq: the connected + * Session is the truth and a cached value never outranks it. A key the store + * has never seen reads `undefined` (capability absent). Faces are identity-stable * per key (create-on-demand, cached) so the React side binds each exactly * once; the store-level channel (`subscribeAny`) serves coarse consumers (the * manager's list projection reads the `title` key). @@ -133,27 +148,54 @@ export class ProjectionValueStore { */ apply(key: string, value: unknown, seq: SessionSeqCursor): void { const row = this.rows.get(key) - if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop - this.rows.set(key, { value, seq }) + // higher seq wins among sequenced rows; replays and stale frames drop. A + // cached row has no comparable seq and always yields. + if (row?.kind === 'sequenced' && seq <= row.seq) return + this.rows.set(key, { kind: 'sequenced', value, seq }) this.changed(key) } /** - * Seed from a history tail page's projections block: every carried key - * lands under the same seq rule as frames; a key the block omits is - * capability-absent as of the cut — its row clears unless a newer frame - * already superseded the cut (a stale baseline can neither overwrite nor - * clear newer values). + * Fill keys from a session-list block the Host labeled `cached`: a zero-I/O + * view of the persisted checkpoint. A cached value lands only where no + * sequenced row exists: a connected Session has already answered for such + * a key, and the list's view of the persisted checkpoint cannot be newer + * than it. + * @param values - whole values by key viewed from the persisted checkpoint. + */ + applyCached(values: Readonly>): void { + for (const key of Object.keys(values)) { + if (this.rows.get(key)?.kind === 'sequenced') continue + this.rows.set(key, { kind: 'cached', value: values[key] }) + this.changed(key) + } + } + + /** + * Seed from a history tail page's projections block. Every cached row is + * discarded first, regardless of seq: the block comes from the connected + * Session, and a value viewed from the persisted checkpoint never outranks + * it. Then every carried key lands under the same seq rule as frames, and a + * key the block omits is capability-absent as of the cut — its row clears + * unless a newer frame already superseded the cut (a stale baseline can + * neither overwrite nor clear newer sequenced values). * @param baseline - the response's projections block. */ seed(baseline: ProjectionsBaseline): void { + for (const [key, row] of this.rows) { + if (row.kind !== 'cached') continue + this.rows.delete(key) + this.changed(key) + } // Erased walk: the framework crosses the open key space; per-key typing // is re-established at the consumer (useProjection's map lookup). const values = baseline.values as Record for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq) for (const [key, row] of this.rows) { if (Object.hasOwn(values, key)) continue - if (row.seq > baseline.asOfSeq) continue + // Every cached row was deleted above; the kind test only narrows the + // type so `row.seq` is readable. + if (row.kind === 'sequenced' && row.seq > baseline.asOfSeq) continue this.rows.delete(key) this.changed(key) } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index c80688c9ae..be20eafe42 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -54,7 +54,7 @@ export interface SessionSummary { export interface SessionListState { /** Host list order; every id has a matching byId row in the same snapshot. */ ids: SessionId[] - /** Host/catalog rows plus local fallback rows for live Client generations; only `ids` expresses Host-list membership. */ + /** Host/catalog rows plus retained subagent fallbacks; only `ids` expresses Host-list membership. */ byId: Record /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ phase: SessionListPhase @@ -470,14 +470,8 @@ export class ClientSessions implements ISessions { this.projectList() const childId = result.value.sessionId if (sourceTitle !== undefined) { - const reference = this.retain(childId, { source: 'controllerOperation' }) - try { - await reference.ready - const renamed = await reference.binding.session.rename(increasedForkTitle(sourceTitle)) - if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`) - } finally { - reference.release() - } + const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle)) + if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`) } return childId } @@ -558,7 +552,9 @@ export class ClientSessions implements ISessions { if (count === 0) this.retireScope(id, record) else this.publishRetention(id) }) - if (this.list.getSnapshot().byId[id] === undefined) this.projectList() + if (this.list.getSnapshot().byId[id] === undefined && this.manager.subagentAddress(id) !== undefined) { + this.projectList() + } this.publishRetention(id) return reference } @@ -667,15 +663,22 @@ export class ClientSessions implements ISessions { } for (const [id, record] of this.scopes) { if (byId[id] !== undefined) continue + const address = this.manager.subagentAddress(id) + if (address === undefined) continue const previous = previousById[id] const snapshot = record.session.getSnapshot() - const address = this.manager.subagentAddress(id) + const projectionValues = this.manager.projectionValues(id) + const projectedTitle = projectionValues?.title + const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : previous?.title byId[id] = { ...(previous ?? { id, displayTitle: id, updatedAt: 0 }), running: snapshot.running, retainedBy: record.retention.retainedBy, blank: snapshot.blank, - ...(address === undefined ? {} : { parentId: address.parentSessionId, origin: 'subagent' }), + parentId: address.parentSessionId, + origin: 'subagent', + ...(projectionValues === undefined ? {} : { projectionValues }), + ...(title === undefined ? {} : { title, displayTitle: title }), } } this.list.set({ ids, byId, phase, projectionsBySession, jobsBySession }) diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index cd38a26fcf..fb1eb7a01d 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -137,8 +137,9 @@ export class Session implements SessionFace { * Per-session projection value store (push model; see the session-projection * subsystem page, docs/subsystems/session-projection.md): finished whole * values computed on the Host, seeded by the tail page's - * projections block and updated by Session Controller control frames under the - * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)` + * projections block and updated by Session Controller control frames; + * Host-sequenced writes merge under higher-seq-wins and cached list blocks + * yield to them (projection-store.ts). Keys are read via `projections.faceOf(key)` * (the useProjection resolution face); the conversation snapshot never * carries projection values, and no client-side domain folding exists. * Manager-owned when constructed through SessionManager (frames route and diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index fa456772de..03c697734b 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -620,19 +620,16 @@ function imageBlockIn( if (!Array.isArray(content)) return undefined for (const value of content) { if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown } + const block = value as { readonly type?: unknown; readonly attachment?: unknown } if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { const ref = block.attachment as ImageAttachmentRef if (match(ref)) return ref } - if (block.type === 'tool-result') { - const nested = imageBlockIn(block.content, match) - if (nested !== undefined) return nested - } } return undefined } +/** Read only first-party declared content fields; unknown event payloads stay opaque. */ function imageInEvent( event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean, @@ -640,21 +637,45 @@ function imageInEvent( const data = event.data as { readonly content?: unknown readonly message?: { readonly content?: unknown } - readonly inserted?: readonly { readonly content?: unknown }[] + readonly inserted?: unknown + readonly summary?: unknown + readonly rawOutput?: unknown } - const direct = imageBlockIn(data.content, match) - if (direct !== undefined) return direct - const message = imageBlockIn(data.message?.content, match) - if (message !== undefined) return message - for (const inserted of data.inserted ?? []) { - const found = imageBlockIn(inserted.content, match) - if (found !== undefined) return found - } - if (event.type === 'assistant/message' || event.type === 'assistant/attempt') { - for (const chunk of assistantStreamChunks(event.data.stream, 'block-end')) { - const found = imageBlockIn([chunk.block], match) - if (found !== undefined) return found + // First-party event payloads can be present without their producer plugin mounted. + const type: string = event.type + switch (type) { + case 'user/message': + case 'tool/ptc-dispatch': + return imageBlockIn(data.content, match) + case 'system/message': + case 'developer/message': + case 'tool/result': + case 'team/message/queued': + return imageBlockIn(data.message?.content, match) + case 'agent/inbox/spliced': { + const messages = data.inserted + if (!Array.isArray(messages)) return undefined + for (const message of messages as readonly unknown[]) { + if (typeof message !== 'object' || message === null || Array.isArray(message)) continue + const found = imageBlockIn((message as { readonly content?: unknown }).content, match) + if (found !== undefined) return found + } + return undefined } + case 'compaction/summary': + return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match) + case 'assistant/message': { + const found = imageBlockIn(data.message?.content, match) + if (found !== undefined) return found + break + } + case 'assistant/attempt': break + default: return undefined + } + const assistant = event as SessionEvent<'assistant/message' | 'assistant/attempt'> + for (const chunk of assistantStreamChunks(assistant.data.stream, 'block-end')) { + const found = imageBlockIn([chunk.block], match) + if (found !== undefined) return found } return undefined } diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index 530414c52d..47fdbbdff8 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -3,9 +3,8 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent-presets' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' @@ -272,21 +271,16 @@ export class ApiSessionList { session: Session | undefined, ): SessionProjectionHints | undefined { try { + if (session !== undefined) { + // The live registry computed the block for this Session: its watermark + // shares the sequence space of the Session's baselines and frames. + return hintsOf('sequenced', this.ctx.sessionProjections.cachedSnapshot(session)) + } + // A cold row reads the persisted cache by header alone; the cache serves + // seeded and unseeded lifecycles alike because a listing never seeds a + // fold. The watermark is the stored record's own. const cache = this.ctx.get('sessionProjectionCache') - const block = session === undefined - ? header.isSeeded - ? undefined - : cache?.cachedSnapshot(header, SessionLogOffset(0)) - ?? cache?.cachedPredecessorTitle(header, SessionLogOffset(0)) - : this.ctx.sessionProjections.cachedSnapshot(session) - return block !== undefined && Object.keys(block.values).length > 0 - ? { - asOfSeq: block.asOfSeq, - // Listing hints contain every currently cached wire value but remain - // partial: missing cells and cache rows are never materialized here. - values: block.values as SessionProjectionValues, - } - : undefined + return hintsOf('cached', cache?.cachedSnapshot(header) ?? cache?.cachedPredecessorTitle(header)) } catch (error) { this.ctx.logger.warn( `api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`, @@ -296,6 +290,22 @@ export class ApiSessionList { } } +/** + * Wrap one projection block as Session-list hints of the named sequence space. + * @param kind - which sequence space the block's watermark belongs to. + * @param block - the block, or `undefined` when no source served one. + * @returns the hints, or `undefined` when the block is absent or carries no value. + */ +function hintsOf( + kind: SessionProjectionHints['kind'], + block: ProjectionSnapshot | undefined, +): SessionProjectionHints | undefined { + if (block === undefined || Object.keys(block.values).length === 0) return undefined + // Listing hints contain every wire value the source currently holds but + // remain partial: missing cells and cache rows are never materialized here. + return { kind, asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues } +} + function normalizeSearchQuery(query: string): string { const normalized = query.trim() if (normalized.length === 0) { diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a925e9da83..37294a6dbb 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -49,10 +49,24 @@ export interface SessionListMetadata { readonly lastPromptAt: number | null } -/** Every available cached wire value used as partial, possibly stale Session-list hints. */ +/** + * Every available wire value a Session-list row carries as partial, possibly + * stale hints. `kind` and `asOfSeq` are independent facts: `kind` says which + * sequence space `asOfSeq` belongs to, and therefore how a client may merge + * the block; `asOfSeq` is the producer's watermark in that space. + */ export interface SessionProjectionHints { + /** + * `sequenced`: the Host's live registry produced the block for an attached + * Session, so `asOfSeq` is comparable with baselines and frames of the same + * connection. `cached`: a header-only listing viewed the block from the + * persisted projection cache, so `asOfSeq` is the stored record's own + * watermark and must not be compared with the connected Session's values. + */ + readonly kind: 'cached' | 'sequenced' + /** Watermark of the block in the sequence space named by `kind`. */ readonly asOfSeq: number - /** Provider-validated values present in the cache; omitted keys remain unknown. */ + /** Provider-validated values present in the block; omitted keys remain unknown. */ readonly values: SessionProjectionValues } diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 3f1a45967d..a22e35cee4 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -316,7 +316,8 @@ function imageRef(id: string): ImageAttachmentRef { } function event(type: string, seq: SessionSeq, data: unknown): SessionEvent { - return { type, seq, time: seq + 1, data } as SessionEvent + const surface = ['user/message', 'system/message', 'developer/message', 'tool/result'].includes(type) + return { type, seq, time: seq + 1, data, ...surface ? { surfaceOp: 'append' } : {} } as SessionEvent } async function persistedController( @@ -348,16 +349,97 @@ async function persistedController( } describe('Session attachment authorization', () => { - it('finds references in direct, message, inserted, nested, and streamed content', async () => { - const nested = imageRef('nested') + it.each([ + ['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'], + ['team/message/queued', 'message'], ['tool/ptc-dispatch', 'content'], + ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'], + ] as const)('reads the declared %s %s content without rewriting it', async (type, field) => { + const ref = imageRef(`${type}-${field}`) + const content = [{ type: 'image', attachment: ref }] + const message = type === 'team/message/queued' ? { content } + : type === 'tool/result' ? { + id: 'declared-message', role: 'tool', source: { kind: 'tool', callId: 'declared-call' }, + toolCallId: 'declared-call', isError: false, content, + } : { + id: 'declared-message', role: type === 'developer/message' ? 'developer' : 'system', + source: { kind: 'system-prompt' }, content, + } + const data = field === 'message' ? { message } : { [field]: content } + const events = [event(type, SessionSeq(0), data)] + const saved = JSON.stringify(events) + const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) + const fixture = await persistedController(events, readImage) + try { + await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) + .resolves.toEqual({ attachment: ref, data: 'AQ==' }) + expect(readImage).toHaveBeenCalledExactlyOnceWith(ref) + expect(JSON.stringify(events)).toBe(saved) + } finally { + await fixture.ctx.fiber.dispose() + } + }) + + it('does not authorize an attachment through malformed inbox entries', async () => { + const ref = imageRef('not-referenced') + for (const value of [undefined, null, {}, [null, 1, []]]) { + const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) + const fixture = await persistedController([event('agent/inbox/spliced', SessionSeq(0), { inserted: value })], readImage) + try { + await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) + expect(readImage).not.toHaveBeenCalled() + } finally { + await fixture.ctx.fiber.dispose() + } + } + }) + + it('ignores unrelated fields of a known message event and its content blocks', async () => { + const ref = imageRef('not-a-content-occurrence') + const content = [{ type: 'image', attachment: ref }] + const stored = event('user/message', SessionSeq(0), { + id: 'metadata', role: 'user', source: { kind: 'user' }, + content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }], + message: { content }, inserted: [{ content }], + }) + const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) + const fixture = await persistedController([stored], readImage) + try { + await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) + .rejects.toMatchObject({ code: 'session/attachment-invalid' }) + expect(readImage).not.toHaveBeenCalled() + } finally { + await fixture.ctx.fiber.dispose() + } + }) + + it.each(['external/image-record', 'session/title-llm-request'])('denies attachment access from %s without an attachment carrier', async (type) => { + const ref = imageRef('opaque-event-image') + const content = [{ type: 'image', attachment: ref }] + const opaque = { ...event(type, SessionSeq(0), { + content, message: { content }, inserted: [{ content }], messages: [{ content }], + stream: [{ type: 'chunk', time: 1, chunk: { type: 'block-end', index: 0, block: content[0] } }], + }), ignorable: true as const } + const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) + const fixture = await persistedController([opaque], readImage) + try { + await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) + expect(readImage).not.toHaveBeenCalled() + } finally { + await fixture.ctx.fiber.dispose() + } + }) + + it('finds references in direct, message, inserted, and streamed content', async () => { + const direct = imageRef('direct') const message = imageRef('message') const inserted = imageRef('inserted') const streamed = imageRef('streamed') const events: SessionEvent[] = [ - { ...event('fixture/direct', SessionSeq(0), { - content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, { - type: 'tool-result', content: [{ type: 'image', attachment: nested }], - }], + { ...event('user/message', SessionSeq(0), { + id: 'direct', role: 'user', source: { kind: 'user' }, + content: [null, [], { type: 'text', text: 'none' }, { type: 'image', attachment: direct }], }), ignorable: true as const }, { type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append', @@ -374,7 +456,7 @@ describe('Session attachment authorization', () => { event('agent/inbox/spliced', SessionSeq(2), { target: 'next-turn', start: 0, - inserted: [createUserMessage({ + inserted: [null, 1, [], { content: [] }, createUserMessage({ content: [{ type: 'image', attachment: inserted }], source: { kind: 'user' }, })], @@ -408,7 +490,7 @@ describe('Session attachment authorization', () => { const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) })) const { ctx, controller, sessionId } = await persistedController(events, readImage) - for (const ref of [nested, message, inserted, streamed]) { + for (const ref of [direct, message, inserted, streamed]) { await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId })) .resolves.toEqual({ attachment: ref, data: 'AQ==' }) } @@ -451,7 +533,7 @@ describe('Session attachment authorization', () => { ]) { const ref = imageRef(`failure-${thrown.name}`) const fixture = await persistedController( - [event('fixture/content', SessionSeq(0), { content: [{ type: 'image', attachment: ref }] })], + [event('user/message', SessionSeq(0), { id: 'failure', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })], () => Promise.reject(thrown), ) await expectFailure(fixture.controller.attachment({ diff --git a/packages/api/session-controller/tests/inbox-projection.client.spec.ts b/packages/api/session-controller/tests/inbox-projection.client.spec.ts index 589aa43d09..9ae17056e6 100644 --- a/packages/api/session-controller/tests/inbox-projection.client.spec.ts +++ b/packages/api/session-controller/tests/inbox-projection.client.spec.ts @@ -64,7 +64,8 @@ describe('Inbox projection intake', () => { const stale = { ...empty, 'next-turn': [message('removed', 'already removed')] } const result = ok({ items: [{ sessionId: SID, updatedAt: 1, running: false, blank: false, agentAvailable: false, - projections: { asOfSeq: 21, values: { inbox: empty } }, + // The Session is attached: the Host's live registry served the block. + projections: { kind: 'sequenced' as const, asOfSeq: 21, values: { inbox: empty } }, }] }) let refreshed: Promise | undefined @@ -109,9 +110,9 @@ describe('Inbox projection intake', () => { const baseline = { type: 'baseline', value: { jobs: {}, projections: {} } } as const const result = ok({ items: [ { sessionId: SID, updatedAt: 1, running: false, blank: false, agentAvailable: false, - projections: { asOfSeq: 1, values: { inbox: empty } } }, + projections: { kind: 'cached' as const, asOfSeq: 1, values: { inbox: empty } } }, { sessionId: hiddenSessionId, updatedAt: 1, running: false, blank: false, agentAvailable: false, - projections: { asOfSeq: 1, values: { inbox: restored } } }, + projections: { kind: 'cached' as const, asOfSeq: 1, values: { inbox: restored } } }, ] }) let refreshed: Promise | undefined diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 7ee5be5397..b41ee673c8 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -173,7 +173,7 @@ describe('list lifecycle', () => { it('keeps an opened conversation out of blank-session reuse after a stale list refresh', async ({ mock, remote }) => { const stale = { ...summary(S1, { blank: true, cwd: '/workspace' }), - projections: { asOfSeq: 2, values: { + projections: { kind: 'cached' as const, asOfSeq: 2, values: { sessionListMetadata: { blank: true, lastPromptAt: null }, } }, } @@ -203,16 +203,19 @@ describe('list lifecycle', () => { } }) - it('projects cold Session additions and an empty-cut control baseline before history exists', async ({ mock, remote }) => { + it('projects a cold Session addition as cached until an empty-cut control baseline replaces it', async ({ mock, remote }) => { const manager = makeManager(mock, remote) try { manager.handleSessionAdded({ - ...summary(S1), projections: { asOfSeq: -1, values: { title: 'before history' } }, + ...summary(S1), projections: { kind: 'cached', asOfSeq: -1, values: { title: 'before history' } }, }) + expect(manager.getListSnapshot().items[0]?.title).toBe('before history') + // The control baseline is the connected Session's own value: it replaces + // the list-surface title even at the same cursor. manager.handleControlFrame({ type: 'baseline', value: { jobs: { [S1]: [] }, projections: { [S1]: { asOfSeq: -1, values: { title: 'cold baseline' } } } }, }) - expect(manager.getListSnapshot().items[0]?.title).toBe('before history') + expect(manager.getListSnapshot().items[0]?.title).toBe('cold baseline') expect(manager.getListSnapshot().jobsBySession).toEqual({}) } finally { await manager.dispose() @@ -317,23 +320,23 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) - it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async ({ mock, remote }) => { + it('fills cold titles from the list rows\' projections block as cached values', async ({ mock, remote }) => { const manager = makeManager(mock, remote) - // A push frame landed before the list (S2's title is newer than the block's cut). + // A push frame landed before the list: S2's title is a sequenced value. manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9, }) remote.session.list.mockResolvedValue(ok({ items: [ - { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } }, - { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } }, + { ...summary(S1), projections: { kind: 'cached', asOfSeq: 4, values: { title: 'Cold cached' } } }, + { ...summary(S2, { updatedAt: 200 }), projections: { kind: 'cached', asOfSeq: 50, values: { title: 'List stale' } } }, ] as never[], })) await manager.refreshList() const items = manager.getListSnapshot().items // Cold row: title surfaces straight from the list block — no open, no history. expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') - // The stale list block (seq 5) cannot overwrite the newer push frame (seq 9). + // A cached list block never displaces a sequenced value, whatever watermark its record holds. expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') }) @@ -882,10 +885,10 @@ describe('connected generation', () => { remote.session.list.mockImplementation(() => calls++ === 0 ? oldList.promise : newList.promise) const manager = makeManager(mock, remote) const oldResult = ok({ items: [{ ...summary(S1), projections: { - asOfSeq: 20, values: { title: 'Unpersisted title' }, + kind: 'cached', asOfSeq: 20, values: { title: 'Unpersisted title' }, } }] as never[] }) const newResult = ok({ items: [{ ...summary(S1), projections: { - asOfSeq: 1, values: { title: 'Durable title' }, + kind: 'cached', asOfSeq: 1, values: { title: 'Durable title' }, } }] as never[] }) const oldPull = manager.refreshList() let newPull: Promise | undefined diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 2ab2bbaec6..3ab6ab2de1 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -1,11 +1,12 @@ /** * Projection value store (push model; session-projection subsystem page: - * docs/subsystems/session-projection.md): the single - * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a - * newer push frame; a replayed frame cannot regress), capability absence as - * undefined, generation invalidation, and the Session/manager wiring (tail-page - * seeding, control-stream projection routing pre- and post-instantiation, the - * list rows' title projection). + * docs/subsystems/session-projection.md): the higher-seq-wins rule among + * sequenced writes (a stale baseline cannot overwrite a newer push frame; a + * replayed frame cannot regress), cached list values yielding to every + * sequenced write regardless of seq, capability absence as undefined, + * generation invalidation, and the Session/manager wiring (tail-page seeding, + * control-stream projection routing pre- and post-instantiation, the list + * rows' title projection). */ import { describe, expect } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' @@ -75,6 +76,48 @@ describe('Session projection value semantics', () => { expect(store.get('test/marks')).toBeUndefined() }) + it('cached values fill empty keys only and never displace a sequenced row', () => { + const store = new ProjectionValueStore() + store.applyCached({ 'test/marks': { marks: ['cached'] }, title: 'Cached title' }) + expect(store.values()).toEqual({ 'test/marks': { marks: ['cached'] }, title: 'Cached title' }) + // A later cached view replaces an earlier one: neither carries a seq. + store.applyCached({ title: 'Cached again' }) + expect(store.get('title')).toBe('Cached again') + // Once a sequenced row exists, cached values for that key are ignored. + store.apply('title', 'Pushed', SessionSeq(0)) + store.applyCached({ title: 'Cached late', 'test/marks': { marks: ['cached late'] } }) + expect(store.get('title')).toBe('Pushed') + expect(store.get('test/marks')).toEqual({ marks: ['cached late'] }) + }) + + it('every sequenced write outranks a cached row regardless of seq', () => { + const store = new ProjectionValueStore() + store.applyCached({ 'test/marks': { marks: ['cached'] }, title: 'Cached title', schedule: [] }) + // A frame at the lowest cursor still replaces the cached value. + store.apply('title', 'Frame at -1', -1) + expect(store.get('title')).toBe('Frame at -1') + // A baseline discards every cached row first: the carried key lands at its + // cut, the omitted keys clear even though a cached row has no seq to compare. + store.seed({ asOfSeq: SessionSeq(2), values: { 'test/marks': { marks: ['baseline-2'] } } }) + expect(store.values()).toEqual({ 'test/marks': { marks: ['baseline-2'] } }) + // The same baseline rule keeps protecting newer sequenced rows. + store.apply('title', 'Frame at 9', SessionSeq(9)) + store.applyCached({ title: 'Cached late' }) + store.seed({ asOfSeq: SessionSeq(5), values: { 'test/marks': { marks: ['baseline-5'] } } }) + expect(store.values()).toEqual({ 'test/marks': { marks: ['baseline-5'] }, title: 'Frame at 9' }) + }) + + it('notifies faces for cached fills and for their discard by a baseline', async () => { + const store = new ProjectionValueStore() + const observed: unknown[] = [] + store.faceOf('title').subscribe(() => { observed.push(store.get('title')) }) + store.applyCached({ title: 'Cached title' }) + await Promise.resolve() + store.seed({ asOfSeq: SessionSeq(3), values: {} }) + await Promise.resolve() + expect(observed).toEqual(['Cached title', undefined]) + }) + it('clears all generation watermarks without replacing subscribed faces', async () => { const store = new ProjectionValueStore() const face = store.faceOf('test/marks') @@ -206,12 +249,79 @@ describe('manager frame routing', () => { expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title') }) + it('routes a cached list block below every sequenced write: a Session baseline at any cut replaces it and a list refresh cannot restore it', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + remote.session.list.mockResolvedValue(ok({ + items: [{ agentAvailable: false, + sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, + // A cold row viewed from a stale record whose own watermark outruns + // the connected Session's cut. + projections: { kind: 'cached', asOfSeq: 40, values: { title: 'Cached title', 'test/marks': { marks: ['cached'] } } }, + }], + })) + await manager.refreshList() + expect(manager.getListSnapshot().items[0]?.title).toBe('Cached title') + + // The connected Session answers at a lower cut: it still wins outright, + // and the key it omits clears rather than surviving on its stale seq. + manager.handleControlFrame({ + type: 'baseline', + value: { + jobs: {}, + projections: { [sid('s1')]: { asOfSeq: 2, values: { title: 'Connected title' } } }, + }, + }) + await Promise.resolve() + expect(manager.getListSnapshot().items[0]?.title).toBe('Connected title') + expect(manager.getListSnapshot().items[0]?.projectionValues).toEqual({ title: 'Connected title' }) + + // A later list refresh serving the stale block again cannot displace the + // sequenced value, whatever watermark the block claims. + await manager.refreshList() + expect(manager.getListSnapshot().items[0]?.title).toBe('Connected title') + expect(manager.get(sid('s1')).projections.get('test/marks')).toEqual({ marks: ['cached'] }) + }) + + it('merges a sequenced list block under higher-seq-wins: a lower-cut baseline neither overwrites nor clears it', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + remote.session.list.mockResolvedValue(ok({ + items: [{ agentAvailable: true, + sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, + // The Host's live registry served the block: its watermark shares the connection's seq space. + projections: { kind: 'sequenced', asOfSeq: 40, values: { title: 'Live title', 'test/marks': { marks: ['live'] } } }, + }], + })) + await manager.refreshList() + expect(manager.getListSnapshot().items[0]?.title).toBe('Live title') + + // A delayed baseline at a lower cut is stale against the block: it can + // neither overwrite the carried key nor clear the omitted one. + manager.handleControlFrame({ + type: 'baseline', + value: { + jobs: {}, + projections: { [sid('s1')]: { asOfSeq: 2, values: { title: 'Delayed baseline' } } }, + }, + }) + await Promise.resolve() + expect(manager.getListSnapshot().items[0]?.title).toBe('Live title') + expect(manager.get(sid('s1')).projections.get('test/marks')).toEqual({ marks: ['live'] }) + + // A frame past the block's watermark still advances it. + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Frame title', seq: 41, + }) + await Promise.resolve() + expect(manager.getListSnapshot().items[0]?.title).toBe('Frame title') + }) + it('projects every retained value into list rows with stable snapshot identity', async ({ mock, remote }) => { const manager = makeManager(mock, remote) remote.session.list.mockResolvedValue(ok({ items: [{ agentAvailable: true, sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, projections: { + kind: 'sequenced', asOfSeq: 2, values: { 'test/marks': { marks: ['baseline'] } }, }, diff --git a/packages/api/session-controller/tests/reference-ownership.client.spec.ts b/packages/api/session-controller/tests/reference-ownership.client.spec.ts index 25253dfc24..16de92ef02 100644 --- a/packages/api/session-controller/tests/reference-ownership.client.spec.ts +++ b/packages/api/session-controller/tests/reference-ownership.client.spec.ts @@ -119,7 +119,7 @@ describe('Client reference sources', () => { expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy) b.svc.handleSessionRemoved(ID) await vi.waitFor(() => { - expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy) + expect(b.svc.list.getSnapshot().byId[ID]).toBeUndefined() }) expect(b.svc.list.getSnapshot().ids).not.toContain(ID) expect(source.getSnapshot()).toBe(snapshot) @@ -138,7 +138,7 @@ describe('Client reference sources', () => { expect(reference.binding.session.getSnapshot().openState).toBe('cold') expect(source.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { gateway: 1 } }) expect(b.svc.list.getSnapshot().ids).not.toContain(ID) - expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({ gateway: 1 }) + expect(b.svc.list.getSnapshot().byId[ID]).toBeUndefined() expect(mock.log.requests(FOLLOW)).toHaveLength(0) expect(mock.remote.session.projections).not.toHaveBeenCalled() await b.feed(true) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index c8be6f2252..c715bbf9ab 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -97,7 +97,7 @@ describe('sessions.list cold merge', () => { ctx.provide('sessionProjectionCache', { cachedSnapshot: () => undefined, cachedPredecessorTitle: (meta: SessionHeader) => meta.id === sid('legacy-title') - ? { asOfSeq: -1, values: { title: 'Cached predecessor title' } } + ? { asOfSeq: 2, values: { title: 'Cached predecessor title' } } : undefined, } as never) const remote = createSessionTestRemote(ctx, { @@ -119,7 +119,7 @@ describe('sessions.list cold merge', () => { sessionId: sid('legacy-title'), blank: false, updatedAt: 100, - projections: { asOfSeq: -1, values: { title: 'Cached predecessor title' } }, + projections: { kind: 'cached', asOfSeq: 2, values: { title: 'Cached predecessor title' } }, }), ]) expect(stat).not.toHaveBeenCalled() @@ -152,6 +152,12 @@ describe('sessions.list cold merge', () => { if (meta.id === sid('cached-conversation')) { return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } } } + if (meta.id === sid('seeded-cold')) { + return { + asOfSeq: 5, + values: { title: 'Forked title', sessionListMetadata: { blank: false, lastPromptAt: 1200 } }, + } + } return undefined }, cachedPredecessorTitle: () => undefined, @@ -172,10 +178,18 @@ describe('sessions.list cold merge', () => { origin: 'subagent', }) expect(byId['missing-cwd']).toBeUndefined() - // A cold seeded header never consults the cache: its cut is not 0, so a - // cut-0 lookup would alias a different projection identity. - expect(byId['seeded-cold']).toMatchObject({ blank: false, updatedAt: 450 }) - expect(cacheCalls).not.toContain('seeded-cold') + // A cold seeded header reads the cache by header alone, like any other + // cold row: the cache binds the lifecycle, and a listing never seeds a fold. + expect(byId['seeded-cold']).toMatchObject({ + blank: false, + updatedAt: 1200, + projections: { + kind: 'cached', + asOfSeq: 5, + values: { title: 'Forked title', sessionListMetadata: { blank: false, lastPromptAt: 1200 } }, + }, + }) + expect(cacheCalls).toContain('seeded-cold') expect(inspect).not.toHaveBeenCalled() }) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 3339a97d14..5b3dd18b42 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -22,6 +22,7 @@ import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepsee import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' +import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title' import Storage from '@deepseek-ai/dsh-storage' import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import * as StorageJson from '@deepseek-ai/dsh-storage-json' @@ -559,6 +560,7 @@ describe('session.list projections column', () => { const row = response.value.items.find(item => item.sessionId === coldId) expect(row?.running).toBe(false) expect(row?.projections).toEqual({ + kind: 'cached', asOfSeq: 7, values: { 'test/last-user': { text: 'cached' }, @@ -621,6 +623,89 @@ describe('session.list projections column', () => { } }) + it('lists a forked Session\'s cached title after a Host restart without opening its body', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-api-projcache-')) + const id = SessionId('session-cold-fork') + /** One Host process over `root`: storage stack, registry with the title unit, and the cache. */ + const boot = async (): Promise => { + const ctx = new Context() + await ctx.plugin(Storage) + await ctx.plugin(StorageJson, { root }) + await ctx.plugin(StorageDomain, { backend: 'json' }) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(titleProjectionDefinition) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + return ctx + } + + // First process: a fork inherits its ancestor's prompt and title, the + // cache checkpoints the fold, and the process ends without ever listing. + const first = await boot() + let header: SessionHeader | undefined + let lastSeq: number | undefined + let promptTime: number | undefined + try { + remote(first) + await new Promise(resolve => setTimeout(resolve, 0)) + let child: Session | undefined + const owner = await first.plugin(Object.assign((sessionCtx: Context) => { + const parent = sessionCtx.sessions.create(SessionId('session-cold-fork-parent'), { + meta: { createdAt: 5, cwd: '/workspace' }, + }) + parent.append('turn/start', { turn: 1 }) + promptTime = parent.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'ancestor prompt' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }).time + parent.append('session/title', { title: 'Forked title', messageSeqs: [], source: { kind: 'user' } }) + parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + child = sessionCtx.sessions.fork(parent, undefined, id) + }, { inject: ['sessions'] })) + if (child === undefined) throw new Error('child was not forked') + expect(child.header.isSeeded).toBe(true) + expect(child.inheritedEventCount).toBe(4) + await first.sessionProjectionCache.write(child) + header = child.header + lastSeq = child.seq - 1 + await owner.dispose() + } finally { + await first.fiber.dispose() + } + if (header === undefined || lastSeq === undefined || promptTime === undefined) throw new Error('unreachable') + + // Second process: persistence knows the header, the cache holds the + // record, and nothing opens the log. + const second = await boot() + try { + const gateway = remote(second) + await new Promise(resolve => setTimeout(resolve, 0)) + const load = () => { throw new Error('a cold fork listing must not load the event log') } + second.provide('sessionPersistence', testSessionPersistence(second, { + list: async () => [header], + inspect: load, + open: load, + }) as never) + + const response = await gateway.list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === id) + // Cold recency is the later of the header's creation time and the cached + // last prompt; the fork's creation time is wall-clock and may trail the + // inherited prompt by a tick. + expect(row).toMatchObject({ blank: false, updatedAt: Math.max(header.createdAt, promptTime) }) + expect(row?.projections).toMatchObject({ + kind: 'cached', + asOfSeq: lastSeq, + values: { title: 'Forked title', sessionListMetadata: { blank: false, lastPromptAt: promptTime } }, + }) + } finally { + await second.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }) + it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { const { ctx } = await harness(true) const coldId = SessionId('session-cold-uncached') diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index fa0d65f857..52da57850d 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -71,7 +71,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise { ...(r.origin !== undefined ? { origin: r.origin } : {}), ...(r.projections === undefined ? {} - : { projections: { asOfSeq: 0, values: r.projections } }), + : { projections: { kind: 'sequenced' as const, asOfSeq: 0, values: r.projections } }), })), })) await b.svc.refresh() @@ -934,6 +934,9 @@ describe('fork', () => { expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: 'source', atSeq: 7 }) expect(b.mock.remote.session.rename).toHaveBeenCalledExactlyOnceWith({ sessionId: 'child', title: childTitle }) + expect(b.mock.log.requests(FOLLOW)).toHaveLength(0) + expect(b.svc.binding(sid('child'))).toBeUndefined() + expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0) await Promise.resolve() expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({ title: childTitle, @@ -964,7 +967,7 @@ describe('fork', () => { expect(b.mock.remote.session.rename).not.toHaveBeenCalled() }) - it('rejects child rename failure while preserving its catalog row and releasing the operation', async ({ bench }) => { + it('rejects child rename failure while preserving its catalog row without retaining the child', async ({ bench }) => { const b = bench() b.svc.handleControlFrame({ type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2, @@ -978,11 +981,12 @@ describe('fork', () => { expect(b.svc.list.getSnapshot().byId[sid('child')]).toBeDefined() expect(b.svc.binding(sid('child'))).toBeUndefined() expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0) + expect(b.mock.log.requests(FOLLOW)).toHaveLength(0) }) }) describe('catalog arrival', () => { - it('keeps a retained generation indexed after its Host row is removed', async ({ bench }) => { + it('keeps a retained binding alive without a catalog row after Host removal', async ({ bench }) => { const b = bench() b.svc.handleSessionAdded({ agentAvailable: true, sessionId: sid('s-new'), updatedAt: 1, running: false, blank: true }) await Promise.resolve() @@ -993,9 +997,11 @@ describe('catalog arrival', () => { b.svc.handleSessionRemoved(sid('s-new')) await Promise.resolve() expect(b.svc.list.getSnapshot().ids).not.toContain(sid('s-new')) - expect(b.svc.list.getSnapshot().byId[sid('s-new')]?.retainedBy).toEqual({ controllerOperation: 1 }) + expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toBeUndefined() + expect(b.svc.retainInfo(sid('s-new')).getSnapshot().referenceCount).toBe(1) expect(b.svc.binding(sid('s-new'))).toBe(binding) reference.release() + expect(b.svc.binding(sid('s-new'))).toBeUndefined() expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toBeUndefined() }) }) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 7dfc3671fd..4c885f8834 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -8,9 +8,8 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ReactNode } from 'react' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' -import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx' +import type { PluginManagerPageProps } from '../src/client/index.ts' import type { ConfigLedger } from '../src/client/config-ledger.ts' -import type { PluginConfigViewProps } from '../src/client/slot-contract.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' @@ -102,6 +101,12 @@ function renderTab(state: Partial = {}, config: Partial { + if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) { + throw new Error('Configuration slot requires a summary or page view') + } + return bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`]?.(owner.view) ?? null + } const unusedGlobalHook = (): never => { throw new Error('PluginManagerPage does not consume global runtime hooks') } const props: PluginManagerPageProps = { useSessions: unusedGlobalHook, @@ -115,8 +120,7 @@ function renderTab(state: Partial = {}, config: Partial - bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`]?.(owner.view) ?? null) as PluginManagerPageProps['renderSlot'], + renderSlot, } const { rerender } = render() return { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 8d8279ffea..d8df0b30d6 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 4bc5d53f1fde5ac65015ef023134b163b351797f -README.zh.md: 481a84150f480c87568a91af0311a3d4fcf2c637 +README.md: 51ad53a79a676f013b61213764479a1247de522c +README.zh.md: c41d19af1855c8ab1582fa92b956ef283d3466cf diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4bc5d53f1f..51ad53a79a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -40,7 +40,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. | | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. | -| `Menu` | Dropdown of items, separators, and group labels, with nested submenus. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | +| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader. `aria-hidden`, so the render site owns the name. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 481a84150f..c41d19af18 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -40,7 +40,7 @@ kind: "package-library" | `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 | | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。 | -| `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | +| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading。它是 `aria-hidden` 的,名称由渲染点提供。 | diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index fe769b2d05..190591d13a 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -249,6 +249,14 @@ background: var(--dsw-alias-border-l2); } +/* A data separator directly followed by a row that starts a group draws one + * line, not two; a group start on the list's first row draws none. */ +.separator + .separator, +.separator + .itemWrap > .separator:first-child, +.itemWrap:first-child > .separator:first-child { + display: none; +} + /* Nested card to the right of the parent row. * Bottom-aligned with the parent menu card (grows upward): itemWrap sits in * .list's 3px pad, so bottom: -3px matches the list's outer bottom edge. diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index b165c2da54..80198b0697 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode } from 'react' +import type { CSSProperties, ReactNode, SyntheticEvent } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCheckOutlineRegular } from './icons/index.tsx' @@ -36,6 +36,61 @@ export interface MenuLabel { /** One primary-menu entry: a row, a separator, or a heading label. */ export type MenuEntry = MenuItem | MenuSeparator | MenuLabel +/** Props for one component-rendered menu row. */ +export interface MenuItemButtonProps { + /** Visible row label. */ + children: ReactNode + /** Leading icon (figma .Menu_cell gap 8). */ + icon?: ReactNode + /** Whether the row cannot be activated. */ + disabled?: boolean + /** Destructive row: error-colored text/icon and danger hover fill. */ + danger?: boolean + /** + * Start a new group: a hairline above this row, the same one a + * `{ type: 'separator' }` data entry draws. It comes and goes with the row, + * so a row that renders nothing leaves no stray line; a data separator + * directly before it draws no second line, and the list's first row draws none. + */ + separatorBefore?: boolean + /** Row activation (click, Enter, or Tab on the focused row). */ + onSelect: () => void +} + +/** + * Render one `role="menuitem"` row for a {@link Menu} whose rows are + * components rather than `items` data: the same markup and styling as a data + * row, so it joins the list's keyboard walk and post-selection focus return + * without any shared state. Closing the menu stays the owner's decision, as + * it is for data rows. + * @param props.children - visible row label. + * @param props.icon - optional leading icon. + * @param props.disabled - whether the row cannot be activated. + * @param props.danger - whether to use the destructive row colors. + * @param props.separatorBefore - whether this row starts a new group (hairline above it). + * @param props.onSelect - row activation callback. + * @returns one menu-item row. + */ +export function MenuItemButton({ + children, icon, disabled = false, danger = false, separatorBefore = false, onSelect, +}: MenuItemButtonProps) { + return ( +
+ {separatorBefore &&
} + +
+ ) +} + function isSeparator(entry: MenuEntry): entry is MenuSeparator { return 'type' in entry && entry.type === 'separator' } @@ -57,10 +112,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). - * @param props.items - selectable rows and optional separators. + * @param props.items - selectable data rows and optional separators (default none; with no `children` either, the list is empty). * @param props.selectedId - row shown as selected. * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. - * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). + * @param props.onSelect - data-row activation callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click, Escape, or a window blur * that moved focus into an iframe (the only signal a pointerdown inside a * cross-origin iframe leaves). @@ -84,6 +139,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * scroll/resize; return null to skip placement for that frame. * @param props.footer - rows pinned below the scrolling items area, separated * by a hairline; they stay visible while the items above scroll. + * @param props.children - component rows rendered after `items` in the same + * list, each a `role="menuitem"` button such as {@link MenuItemButton}; they + * share the keyboard walk, the submenu exclusivity, and the post-selection + * focus return. * @param props.selection - how a selected row is marked: a trailing check * (`'check'`, default — figma .Menu_cell) or the hover fill held on the row * with no check (`'fill'`, for icon-labelled rows where a trailing glyph @@ -94,15 +153,16 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * document.body outside the owner's DOM subtree. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className, listClassName }: { +export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className, listClassName }: { open: boolean autoFocus?: boolean anchor: ReactNode - items: readonly MenuEntry[] + items?: readonly MenuEntry[] + children?: ReactNode footer?: readonly MenuEntry[] selectedId?: string | undefined selectedIds?: readonly string[] | undefined - onSelect: (id: string) => void + onSelect?: (id: string) => void onClose: () => void align?: 'start' | 'end' side?: 'bottom' | 'top' | 'right' @@ -357,7 +417,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
{ setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseEnter={hasSub ? () => { setOpenSubmenuId(entry.id) } : undefined} onMouseLeave={() => { setOpenSubmenuId(null) }} > } + items={[{ id: 'a', label: 'Alpha' }]} + onSelect={() => {}} + onClose={() => { setOpen(false) }} + > + { onAction(); setOpen(false) }}>Publish + + ) + } + render() + const trigger = screen.getByRole('button', { name: 'trigger' }) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Alpha', 'Publish']) + // The component row starts a group: one hairline, between the data row and it. + const publishWrap = screen.getByRole('menuitem', { name: 'Publish' }).parentElement + expect(screen.getByRole('separator').nextElementSibling).toBe(screen.getByRole('menuitem', { name: 'Publish' })) + expect(publishWrap?.contains(screen.getByRole('separator'))).toBe(true) + const publish = screen.getByRole('menuitem', { name: 'Publish' }) + trigger.focus() + fireEvent.keyDown(trigger, { key: 'End' }) + expect(document.activeElement).toBe(publish) + fireEvent.keyDown(publish, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Alpha' })) + fireEvent.click(publish) + expect(onAction).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + await act(async () => { await Promise.resolve() }) + expect(document.activeElement).toBe(trigger) + }) + it('disabled item does not select; Escape and outside pointerdown close', () => { const onSelect = vi.fn() const onClose = vi.fn() @@ -362,6 +399,32 @@ describe('Menu', () => { expect(rowClick).not.toHaveBeenCalled() }) + it('closes an open submenu when the pointer or focus reaches a component row', () => { + render( + trigger} + items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]} + onSelect={() => {}} + onClose={() => {}} + > + {}}>Row + , + ) + const parent = screen.getByRole('menuitem', { name: 'Parent' }) + fireEvent.focus(parent) + expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined() + // Rows inside the card are not top-level rows: focusing one keeps it open. + fireEvent.focus(screen.getByRole('menuitem', { name: 'Sub' })) + expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined() + fireEvent.focus(screen.getByRole('menuitem', { name: 'Row' })) + expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull() + fireEvent.mouseEnter(parent.parentElement as HTMLElement) + expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined() + fireEvent.mouseOver(screen.getByRole('menuitem', { name: 'Row' })) + expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull() + }) + it('opens a submenu on hover and selects a nested item', () => { const onSelect = vi.fn() render( diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index 03b356e547..646ecb0d4d 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/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-session/README.md -README.md: 22ece0c11eee6b2a59acbbd8c5a9650b593a7da4 -README.zh.md: 4b52bef28ad065a7ff10f468056723c4d301a0a0 +README.md: df85bf42b0eb6507662ab4e4a26d1fde6e08872e +README.zh.md: 5f54c1db7b5daf2d113717d890dc90eeb2be87f9 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index 22ece0c11e..df85bf42b0 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -10,7 +10,7 @@ English | [中文](README.zh.md) Use this package to expose Session catalog, retain information, and unified UI status through standard Slot hooks. It materializes per-`SessionBinding` hooks and props, while `SessionProvider` can inherit an outer binding or bind an explicit `SessionReference`. It owns process-local pending-interaction and completion-reminder policy without owning Controller transport, history, or references. -Running status comes from Host list baselines or status events. Catalog rows and local fallback rows do not establish running status; main view references still acknowledge completions. +Running status comes from Host list baselines or status events. Subagent catalog rows and retained subagent fallback rows do not establish running status; main view references still acknowledge completions. ## Table of Contents diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index 4b52bef28a..5f54c1db7b 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -10,7 +10,7 @@ kind: "package-reference" 使用本包可通过标准 Slot 钩子公开 Session catalog、retain 信息与统一 UI 状态。它按 `SessionBinding` 物化钩子和 prop,而 `SessionProvider` 可以继承外围 binding 或绑定显式 `SessionReference`。它拥有进程本地的 pending-interaction 与完成提醒策略,但不拥有 Controller transport、历史或 reference。 -运行状态来自 Host 列表基线或状态事件。目录和保留代际的 fallback 行不会建立运行状态;主视图引用仍会确认完成提醒。 +运行状态来自 Host 列表基线或状态事件。Subagent 目录行和已保留子会话的 fallback 行不会建立运行状态;主视图引用仍会确认完成提醒。 ## 目录 diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 1f94bce4bb..5017904d86 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -493,7 +493,7 @@ export class UiSession extends Service { private reconcileStatus(): void { const list = this.sessions.list.getSnapshot() const present = new Set(Object.keys(list.byId) as SessionId[]) - // Rows from catalogs and retained Client bindings do not establish Host running state. + // Subagent catalog rows and retained subagent fallback rows do not establish Host running state. for (const id of list.ids) { const row = list.byId[id] as SessionSummary const previous = this.running.get(id) diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index 58594b2566..75645087ed 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/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-sidebar-terminal/README.md -README.md: a874eb3ce4fed4e66893bf309a36330c355ce843 -README.zh.md: f54c86f24158a1dcf68e82189c74e700cd8e94c5 +README.md: 02f4c60d20a0cebb594d18085212c8c0bfdc2438 +README.zh.md: b8ab03ce8a62c2ba27a9258d12c7e5d08a178a72 diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index a874eb3ce4..02f4c60d20 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -29,9 +29,9 @@ Open the right sidebar and click **New terminal** to open the remembered availab Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A temporary disconnect preserves the screen and offers **Reconnect**, without exposing transport diagnostics. An exited shell remains visible with its exit code and offers **New terminal**; it never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached. -Closing or replacing a terminal tab removes it immediately and ends its process in the background. A cleanup failure shows a small notification with **Retry**; retrying does not reopen the tab. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process. +Closing or replacing a terminal tab removes it immediately and ends its process in the background. Cleanup failures have no notification or manual retry action; saved unfinished close requests are retried when the Client plugin starts. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process. -After reload, the [sidebar restores its layout](../../client/ui-sidebar-right/README.md#state) and each terminal reconnects to its saved Host identity in the original tab. Collapsed and inactive tabs do not create recovery duplicates or change selection. Host terminals without a saved tab association reopen as recovery tabs; recovery failure offers **Retry terminal recovery**. A missing saved process shows a localized unavailable panel with **New terminal**. Clicking it replaces the unavailable tab in place with a fresh terminal; recovery never creates that replacement automatically. +After reload, the [sidebar restores its layout](../../client/ui-sidebar-right/README.md#state) and each terminal reconnects to its saved Host identity in the original tab. Collapsed and inactive tabs do not create recovery duplicates or change selection. Host terminals absent from the saved layout do not reopen automatically and have no UI recovery entry; they remain subject to the controller's unattended idle reclamation and Session/Host disposal. A missing saved process shows a localized unavailable panel with **New terminal**. Clicking it replaces the unavailable tab in place with a fresh terminal; recovery never creates that replacement automatically. The terminal background, default text, cursor, and selection follow the DSH theme, including system preference and theme-token overrides. Theme changes preserve the running shell, output, and application OSC color overrides. Reset commands restore colors to the current DSH defaults. xterm adjusts text toward 4.5:1 contrast; the cursor keeps at least 3:1 contrast against its cell background, including Vim colorschemes. @@ -43,7 +43,7 @@ The terminal background, default text, cursor, and selection follow the DSH them This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. The body loads its package-local `client.terminal.js` chunk when a terminal view mounts, keeping xterm.js and FitAddon out of the startup `client.js`; they then render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. -A Session header contribution queries Host terminals and opens only those without an existing tab association. The terminal controller saves each globally unique content identity's Host association independently and owns content recovery; the sidebar owns layout persistence. A recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work. +The terminal controller saves each globally unique content identity's Host association independently and owns content recovery; the sidebar owns layout persistence. A recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work. At plugin startup, terminal-kind entries in the sidebar's complete open-tab inventory retain matching saved Host identities, including dormant Sessions. This window hold remains independent of React mounts and screen subscriptions. Removing the last matching occurrence releases it; collapsing or switching views does not. The [terminal controller](../../api/terminal-controller/README.md#use-this-package) owns unattended idle reclamation and long-command protection. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index f54c86f241..b8ab03ce8a 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -29,9 +29,9 @@ kind: "package-reference" 双击终端标签标题可重命名。其他页面持有输入权时,点击 **接管输入** 使当前连接可写。暂时断线时保留屏幕并提供 **重新连接**,不展示传输内部错误。已退出的 shell 仍显示退出码并提供 **新建终端**,不会自动重启。已退出的终端仍计入 Session 配额;达到上限时请关闭不用的标签。 -关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。 +关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败没有通知或手动重试操作;已保存的未完成关闭请求会在 Client 插件启动时重试。折叠、切换标签页或 Session、浮动和全屏都保留进程。 -刷新后,[侧栏恢复布局](../../client/ui-sidebar-right/README.zh.md#state),各终端在原标签页中重连保存的 Host 身份。折叠和非当前标签不会产生重复的恢复标签,也不改变选中项。没有保存标签关联的 Host 终端作为恢复标签打开;恢复失败时提供 **重试恢复终端**。保存的进程已消失时,显示本地化的不可用提示和 **新建终端**。点击后在原位置用全新终端替换失效标签;恢复过程不会自动创建替代进程。 +刷新后,[侧栏恢复布局](../../client/ui-sidebar-right/README.zh.md#state),各终端在原标签页中重连保存的 Host 身份。折叠和非当前标签不会产生重复的恢复标签,也不改变选中项。保存布局之外的 Host 终端不会自动打开,也没有 UI 恢复入口;它们仍由 controller 的无人持有空闲回收及 Session/Host 卸载清理管理。保存的进程已消失时,显示本地化的不可用提示和 **新建终端**。点击后在原位置用全新终端替换失效标签;恢复过程不会自动创建替代进程。 终端背景、默认文字、光标和选区跟随 DSH 主题,包括系统偏好和主题令牌覆盖。切换主题会保留运行中的 shell、输出和应用通过 OSC 设置的颜色。颜色重置命令恢复到当前 DSH 默认值。xterm 将文字对比度调整到 4.5:1;光标与所在单元格背景保持至少 3:1 的对比度,包括 Vim 配色方案。 @@ -43,7 +43,7 @@ kind: "package-reference" 插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。正文在 terminal 视图挂载时加载包内 `client.terminal.js` chunk,使 xterm.js 与 FitAddon 不进入启动 `client.js`;加载后由它们负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 -Session header contribution 查询 Host 终端,只打开没有现有标签关联的终端。终端 controller 独立保存每个全局唯一内容身份与 Host 的关联,并负责内容恢复;侧栏负责布局持久化。恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。 +终端 controller 独立保存每个全局唯一内容身份与 Host 的关联,并负责内容恢复;侧栏负责布局持久化。恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。 插件启动时,侧栏完整打开标签清单中的 terminal 条目会持有相匹配的已保存 Host 身份,包括非当前 Session。窗口持有关系独立于 React 挂载和屏幕订阅。删除最后一个匹配的 occurrence 会释放持有关系;折叠或切换视图不会释放。[终端控制器](../../api/terminal-controller/README.zh.md#use-this-package) 负责无人持有时的空闲回收和长命令保护。 diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts index ce45a902fe..4512f74d0b 100644 --- a/packages/client/ui-sidebar-terminal/src/client/index.ts +++ b/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -13,8 +13,8 @@ import { TerminalGuideIcon } from './TerminalIcon.tsx' import { TerminalGuide, type TerminalGuideInjected } from './TerminalGuide.tsx' import { LazyTerminalBody } from './LazyTerminalBody.tsx' import { TerminalTitle } from './TerminalTitle.tsx' -import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx' -import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx' +// import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx' +// import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx' import type { TerminalBodyInjected, TerminalInjected } from './face.ts' import { en, zh } from './locales.ts' @@ -26,9 +26,9 @@ export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'w * @param ctx - Client root Context with sidebar and terminal services. */ export function apply(ctx: Context): void { - let disposed = false - const recovered = new Map>() - ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime') + // let disposed = false + // const recovered = new Map>() + // ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime') ctx.effect(() => { const sync = (): void => { ctx.webTerminals.retainTabs(ctx.sidebarRight.openTabs.getSnapshot().filter(tab => tab.kind === 'terminal')) } const unsubscribe = ctx.sidebarRight.openTabs.subscribe(sync) @@ -81,32 +81,32 @@ export function apply(ctx: Context): void { ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register( { name: 'sidebar.right.pane.tab.title', key: id, locale: namespace, inject }, TerminalTitle, )), 'ui-sidebar-terminal.title') - ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({ - name: 'conversation.session.header.actions', id, locale: namespace, - inject: (sessionId): TerminalRecoveryInjected => ({ - restore: () => { - let pending = recovered.get(sessionId) - if (pending === undefined) { - for (const tab of ctx.sidebarRight.tabsIn(sessionId)) { - if (tab.kind === 'terminal') view(sessionId, tab.id) - } - pending = ctx.webTerminals.recover(sessionId).then((terminals) => { - if (disposed) return - for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', { - params: { terminalId: info.id }, - }) - }).catch((error: unknown) => { recovered.delete(sessionId); throw error }) - recovered.set(sessionId, pending) - } - return pending - }, - }), - }, TerminalRecovery)), 'ui-sidebar-terminal.recovery') - ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id, locale: namespace, - inject: (): TerminalCleanupInjected => ({ - hooks: { closeFailures: ctx.webTerminals.closeFailures }, - retryClose: (terminalId) => { ctx.webTerminals.retryClose(terminalId) }, - }), - }, TerminalCleanup)), 'ui-sidebar-terminal.cleanup') + // ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({ + // name: 'conversation.session.header.actions', id, locale: namespace, + // inject: (sessionId): TerminalRecoveryInjected => ({ + // restore: () => { + // let pending = recovered.get(sessionId) + // if (pending === undefined) { + // for (const tab of ctx.sidebarRight.tabsIn(sessionId)) { + // if (tab.kind === 'terminal') view(sessionId, tab.id) + // } + // pending = ctx.webTerminals.recover(sessionId).then((terminals) => { + // if (disposed) return + // for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', { + // params: { terminalId: info.id }, + // }) + // }).catch((error: unknown) => { recovered.delete(sessionId); throw error }) + // recovered.set(sessionId, pending) + // } + // return pending + // }, + // }), + // }, TerminalRecovery)), 'ui-sidebar-terminal.recovery') + // ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + // name: 'shell.overlay', id, locale: namespace, + // inject: (): TerminalCleanupInjected => ({ + // hooks: { closeFailures: ctx.webTerminals.closeFailures }, + // retryClose: (terminalId) => { ctx.webTerminals.retryClose(terminalId) }, + // }), + // }, TerminalCleanup)), 'ui-sidebar-terminal.cleanup') } diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts index c7ebf7e211..4143053e17 100644 --- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -/** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */ +/** Terminal views, copy, and tab retention follow the plugin lifetime. */ import { createElement } from 'react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { renderToStaticMarkup } from 'react-dom/server' @@ -16,8 +16,6 @@ import { apply as hostApply } from '../src/index.ts' import { TerminalGuide, type TerminalGuideInjected } from '../src/client/TerminalGuide.tsx' import { LazyTerminalBody } from '../src/client/LazyTerminalBody.tsx' import { TerminalTitle } from '../src/client/TerminalTitle.tsx' -import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx' -import { TerminalCleanup, type TerminalCleanupInjected } from '../src/client/TerminalCleanup.tsx' import type { TerminalBodyInjected } from '../src/client/face.ts' import { en, zh } from '../src/client/locales.ts' @@ -27,8 +25,6 @@ vi.mock('../src/client/terminal.tsx', () => ({ TerminalBody: renderedTerminal }) afterEach(() => { cleanup(); renderedTerminal.mockClear() }) -const terminalInfo = (id: string): WebTerminalInfo => ({ id: id as WebTerminalId, title: id, shell: { path: '/bin/sh', name: 'sh', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null }) - async function mountPlugin() { const ctx = new Context() const tabs = new SidebarRightTabRegistry(ctx) @@ -79,7 +75,7 @@ async function mountPlugin() { } } -it('registers terminal views, recovery and cleanup, then releases every contribution on unload', async () => { +it('registers terminal views without recovery or cleanup slots, then releases contributions on unload', async () => { expect(hostApply).not.toThrow() const h = await mountPlugin() try { @@ -96,8 +92,6 @@ it('registers terminal views, recovery and cleanup, then releases every contribu ['sidebar.right.tab.guide.entry', TerminalGuide, 'sidebarTerminal'], ['sidebar.right.pane.tab', LazyTerminalBody, 'sidebarTerminal'], ['sidebar.right.pane.tab.title', TerminalTitle, 'sidebarTerminal'], - ['conversation.session.header.actions', TerminalRecovery, 'sidebarTerminal'], - ['shell.overlay', TerminalCleanup, 'sidebarTerminal'], ]) const sessionId = 'session' as SessionId const launcher = h.entries[0]!.inject(sessionId) as TerminalGuideInjected @@ -132,10 +126,9 @@ it('registers terminal views, recovery and cleanup, then releases every contribu h.setParams(undefined) h.closeHandler(sessionId, { id: 'new-tab', contentId: 'sidebar://terminal/new' } as Parameters[1]) expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', 'sidebar://terminal/new', undefined) - const cleanupFace = h.entries[4]!.inject(sessionId) as TerminalCleanupInjected - expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures) - cleanupFace.retryClose('terminal' as Parameters[0]) - expect(h.terminals.retryClose).toHaveBeenCalledWith('terminal') + expect(h.terminals.recover).not.toHaveBeenCalled() + expect(h.terminals.retryClose).not.toHaveBeenCalled() + expect(h.openTabIn).not.toHaveBeenCalled() } finally { await h.dispose() } @@ -150,78 +143,6 @@ it('loads the terminal body implementation when its registered wrapper mounts', await waitFor(() => { expect(renderedTerminal).toHaveBeenCalledOnce() }) }) -it('restores terminal occurrences before listing unrepresented Host terminals and shares recovery across headers', async () => { - const h = await mountPlugin() - h.tabsIn.mockReturnValue([{ id: 'collapsed-terminal', kind: 'terminal' }, { id: 'file', kind: 'text' }]) - const pending = Promise.withResolvers() - h.terminals.recover.mockImplementationOnce(() => pending.promise) - const sessionId = 'session' as SessionId - const recovery = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected - const remounted = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected - const completion = recovery.restore() - expect(h.terminals.view).toHaveBeenCalledWith(sessionId, 'collapsed-terminal', 'sidebar://terminal/content', undefined, undefined) - expect(h.terminals.view).toHaveBeenCalledOnce() - try { - expect(remounted.restore()).toBe(completion) - expect(h.terminals.recover).toHaveBeenCalledExactlyOnceWith(sessionId) - expect(h.openTabIn).not.toHaveBeenCalled() - pending.resolve([terminalInfo('build'), terminalInfo('tests')]) - await completion - expect(h.openTabIn.mock.calls).toEqual([ - [sessionId, 'terminal', { params: { terminalId: 'build' } }], - [sessionId, 'terminal', { params: { terminalId: 'tests' } }], - ]) - await remounted.restore() - expect(h.terminals.recover).toHaveBeenCalledTimes(1) - expect(h.openTabIn).toHaveBeenCalledTimes(2) - const otherSession = 'other-session' as SessionId - await (h.entries[3]!.inject(otherSession) as TerminalRecoveryInjected).restore() - expect(h.terminals.recover).toHaveBeenLastCalledWith(otherSession) - expect(h.terminals.recover).toHaveBeenCalledTimes(2) - expect(h.openTabIn).toHaveBeenCalledTimes(2) - } finally { - pending.resolve([]) - await completion - await h.dispose() - } -}) - -it('allows a failed Host lookup to be retried without marking the Session recovered', async () => { - const h = await mountPlugin() - const unavailable = new Error('Host unavailable') - h.terminals.recover.mockRejectedValueOnce(unavailable) - const sessionId = 'session' as SessionId - const recovery = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected - try { - await expect(recovery.restore()).rejects.toBe(unavailable) - expect(h.openTabIn).not.toHaveBeenCalled() - await recovery.restore() - await recovery.restore() - expect(h.terminals.recover).toHaveBeenCalledTimes(2) - } finally { - await h.dispose() - } -}) - -it('does not open retained terminals when their lookup completes after plugin unload', async () => { - const h = await mountPlugin() - const pending = Promise.withResolvers() - h.terminals.recover.mockImplementationOnce(() => pending.promise) - const recovery = h.entries[3]!.inject('session' as SessionId) as TerminalRecoveryInjected - const completion = recovery.restore() - try { - expect(h.terminals.recover).toHaveBeenCalledOnce() - await h.dispose() - pending.resolve([terminalInfo('build')]) - await completion - expect(h.openTabIn).not.toHaveBeenCalled() - } finally { - pending.resolve([]) - await completion - await h.dispose() - } -}) - it('retains terminal metadata from dormant layouts and releases its inventory subscription on unload', async () => { const h = await mountPlugin() const terminal = { sessionId: 'inactive' as SessionId, tabId: 't', kind: 'terminal', contentId: 'terminal' } as SidebarRightOpenTab diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index fdcdf0ae9c..d6fe6aa37c 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: dc9ecee1df515f8e9b4183b6d97c29301c848652 -README.zh.md: 065f77ac442943b5683f0d492b56ab7d6668ba99 +README.md: 4280a8888fc4db9bc2360d5c8ad9c4e7ff62c53a +README.zh.md: 6b41555c1b4dbaf2c62776040ac9cd29c0562635 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index dc9ecee1df..4280a8888f 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -1,5 +1,5 @@ --- -description: "Shared Workspace browser and picker plugin for the dsh web client: grouped or flat session rows, add/rename/reorder, search, fork, archive, and the directory-flow picking hole." +description: "Shared Workspace browser and picker plugin for the dsh web client: grouped or flat session rows, management actions, the slot-composed Session row actions, and directory picking." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package lets users browse grouped or flat Session lists, choose a Workspace for a new Session, and manage Workspaces and Sessions through add, rename, reorder, search, fork, archive, and Workspace deletion. Pending interactions appear as warning dots, active scheduled tasks as alarm markers, and subagent-origin Sessions remain hidden. Canonically distinct folder paths remain separate Workspaces. Adding a Workspace requires a composed directory picker; without one, the add action is unavailable. +This package lets users browse grouped or flat Session lists, choose a Workspace for a new Session, and manage Workspaces and Sessions through add, rename, reorder, search, fork, archive, and Workspace deletion; the Session row menu and its hover buttons are slot lists that client plugins extend. Pending interactions appear as warning dots, active scheduled tasks as alarm markers, and subagent-origin Sessions remain hidden. Canonically distinct folder paths remain separate Workspaces. Adding a Workspace requires a composed directory picker; without one, the add action is unavailable. ## Table of Contents @@ -31,7 +31,7 @@ Use the sidebar to browse Workspaces and their Sessions, reorder them, and start Pinned Sessions lead ordinary Sessions in both grouped and flat views. **Last updated** sorts each partition strictly by the latest user prompt or steer time, newest first; pin time does not affect it. **Manual** uses the relative positions in one complete Session sequence, including hidden archives. Returning to Last updated discards the manual layout, and entering Manual again freezes the then-current chronological order. The browser defaults to Last updated and remembers the selected mode across reloads. -Pinning moves the Session to the front of its complete saved sequence without changing the selected mode. It leads the pinned partition in Manual but need not lead it in Last updated. Unpinning leaves the saved position unchanged. Dragging either a pinned or ordinary row edits that same complete sequence and selects Manual; hidden archived members are retained. Missing pinned members prepend in pin-array order, while other missing members append by recency with missing archives last. Supplementation stays in memory until a Session-order write records the complete result. Archive filtering alone changes neither saved positions nor account membership. +Pinning moves the Session to the front of its complete saved sequence without changing the selected mode. It leads the pinned partition in Manual but need not lead it in Last updated. Unpinning leaves the saved position unchanged. Dragging either a pinned or ordinary row edits that same complete sequence and selects Manual; hidden archived members are retained. Missing pinned members prepend in pin-array order. New ordinary forks precede their sources in the complete sequence without inheriting pin membership; visible pins still lead ordinary rows. Other missing members append by recency with missing archives last. Supplementation stays in memory until a Session-order write records the complete result. Archive filtering alone changes neither saved positions nor account membership. The selected blank **New Session** retains its provisional first slot and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining that position in Manual or following its current timestamp in Last updated. A collapsed-group drag uses the target Session identity and keeps the source visible. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable. The [Session pinning and archive decision](../../../.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md) records the ordering and recovery rules. @@ -47,9 +47,9 @@ Collapsed search is one header action beside the view and add actions: activatin ### Managing sessions -The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference, while fork-title assignment uses a temporary `controllerOperation` reference inside Session Controller; both await the reference's initial history opening. Fork forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. +The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action forks at the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, and Archive are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it. -Archive commits without a confirmation dialog and retains the Session's account position. View options control visibility: the default hides archived Sessions, Show archived includes them, and Archived only hides ordinary Sessions. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a toast with undo and filter-menu actions. Unarchive removes the archive mark without restoring a pin or changing the saved position. +Archive commits without a confirmation dialog and retains the Session's account position. View options control visibility: the default hides archived Sessions, Show archived includes them, and Archived only hides ordinary Sessions. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and a "show archived" action that switches the filter directly. Unarchive removes the archive mark without restoring a pin or changing the saved position. A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start. @@ -65,7 +65,7 @@ The value is intentionally best effort for cold Sessions. An identity-matching u ----- -`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. +`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` opens its result only if no later navigation has superseded the request; New Session uses `openWorkspace`. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. @@ -81,6 +81,79 @@ The package is one composition: both target slots are declared by other plugins, Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the `-native` backend's renderless OS-chooser driver, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied; an empty hole means the composition has no picking affordance. This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed. +### Session row actions + +The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), and `archive` (menu 400, button 100) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action. + +An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request. + +#### Packaged client plugin + +Declare `ui-workspace`, `ui-slots`, `ui-renderer`, `client-locale`, and `ui-primitives` as browser/type development dependencies according to the Client dependency policy. The type-only `ui-workspace/client` import loads this package's `SlotMap` declaration; without it, an independently compiled plugin does not know the slot keys. Keep the Component at module scope and own visible copy in the contributing package's locale namespace. + +```tsx +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' +import type { + InjectFace, LocaleDictOf, PropsLocale, PropsRuntime, +} from '@deepseek-ai/dsh-client-ui-slots' +import { exportSession } from './export-session.ts' + +const NS = 'acme.sessionActions' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + 'acme.sessionActions': 'export' + } +} + +const en: LocaleDictOf = { export: 'Export {title}' } +const zh: LocaleDictOf = { export: '导出 {title}' } + +interface ExportRowInjected { + exportSession: (sessionId: SessionId) => void +} + +type ExportRowProps = + PropsRuntime<'sidebar.workspaces.session.menu.item'> + & PropsLocale + & InjectFace + +function ExportRow({ sessionId, displayTitle, useMenuOpenState, exportSession, t }: ExportRowProps) { + const [, setMenuOpen] = useMenuOpenState() + return ( + { setMenuOpen(false); exportSession(sessionId) }}> + {t('export', { title: displayTitle })} + + ) +} + +export const inject = ['slots', 'locale'] + +export function apply(ctx: Context): void { + ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'acme-session-actions: dictionaries') + + // Order 500 places the row after the shipped Archive (400); `separatorBefore` opens the plugin group. + ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({ + name: 'sidebar.workspaces.session.menu.item', + id: 'acme.export-session', + order: 500, + locale: NS, + inject: (): ExportRowInjected => ({ exportSession }), + }, ExportRow)) +} +``` + +`ctx.slots.inject()` is required even if the owner is normally present: it waits for the declaration, removes the contribution when that declaration collapses, and registers it again after restoration. The registration's `inject` factory may close over the plugin's declared Cordis services; the Component receives only the projected data and callbacks. A hover button registers into `sidebar.workspaces.session.row.action` the same way and renders one icon button. + +#### Dynamic client package + +A dynamically loaded browser half follows the same component contract; which modules it can reach depends on its lane. A Module Loader package (`factory(require)`, as in the real Loader/Web fixture) gets `@deepseek-ai/dsh-client-ui-primitives` as an implicit baseline external: resolve `MenuItemButton` through the loader's `require`, do not list the primitive as a runtime dependency or bundle another copy, and declare a development dependency only when source compilation needs its types. A `cordis-client-runner` closure (the audience of the generated Client Slot catalog) cannot import anything: it renders its own `role="menuitem"` ` )} - /> - - - - {/* Rightmost so it lands where the rest-state pin marker sits. */} - {!row.archived && ( - - - - )} + > + {renderSlot( + 'sidebar.workspaces.session.menu.item', + { sessionId: node.id, displayTitle: row.title }, + { hookContext: menuOpenState }, + )} + + {renderSlot('sidebar.workspaces.session.row.action', { sessionId: node.id, displayTitle: row.title })} )}
diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 10224356ca..7d5fc2e79e 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -7,7 +7,12 @@ * rail entry path, each requesting expansion through the owner share. Adding * is the header button's one action, so it raises the directory flow with no * menu in between; the flow and its error dialog live in WorkspacePicker - * (same package — direct composition, no slot between them). + * (same package — direct composition, no slot between them). A Session row's + * "..." menu and hover buttons are the `sidebar.workspaces.session.menu.item` + * and `sidebar.workspaces.session.row.action` lists rendered through this + * entry's `renderSlot`; the actions in them, this package's own included, + * are slot entries with their own behavior, so this component threads no + * action callbacks and hosts no action surface. */ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' @@ -15,14 +20,15 @@ import { Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular, IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular, IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular, - IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, IconWarningOutlineRegular, - IconWorkspaceTreeOutlineRegular, Menu, Modal, Toast, Tooltip, + IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, + IconWorkspaceTreeOutlineRegular, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState, SessionSearchResultItem, } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { ArchivedFilter, GroupNode, SessionNode, SessionOrderBy, SessionRowState } from '../tree.ts' import { @@ -101,27 +107,16 @@ function useNativeDragAcceptance(active: boolean): void { } /** Grouping, ordering, and archived-filter menu; own open state so it resets with the wide chrome. */ -function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrderPick, onArchivedFilterPick, openSeq, t }: { +function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrderPick, onArchivedFilterPick, t }: { groupBy: SessionGroupBy orderBy: SessionOrderBy archivedFilter: ArchivedFilter onGroupPick: (mode: SessionGroupBy) => void onOrderPick: (mode: SessionOrderBy) => void onArchivedFilterPick: (filter: ArchivedFilter) => void - /** Each bump above zero opens the menu (the archive toast's filter action). */ - openSeq?: number t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) - // Seeded with the mount-time value: a chrome remount (e.g. window resize - // through the rail breakpoint) must not replay an old toast-action bump. - const seenOpenSeq = useRef(openSeq ?? 0) - useEffect(() => { - if (openSeq !== undefined && openSeq > seenOpenSeq.current) { - seenOpenSeq.current = openSeq - setOpen(true) - } - }, [openSeq]) return ( & { +> & PropsRenderSlots<'sidebar.workspaces.session.menu.item' | 'sidebar.workspaces.session.row.action'> & { /** Always-mounted Session list snapshot. */ list: SessionListState /** Host account home for POSIX hover-path abbreviation. */ @@ -258,14 +250,8 @@ type SessionTreeProps = Pick< onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void - /** Open the browser-owned session rename dialog. */ - onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void - /** Archive a Session; the selected filter determines its visibility. */ - onSessionArchive: (sessionId: SessionNode['id']) => void - /** Unarchive a session (row menu action on archived rows). */ - onSessionUnarchive: (sessionId: SessionNode['id']) => void - /** Pin or unpin a session (row menu action; `pin` false unpins). */ - onSessionPin: (sessionId: SessionNode['id'], pin: boolean) => void + /** Open the rename dialog from a row title double-click. */ + onSessionRenameRequest: (sessionId: SessionNode['id'], currentTitle: string) => void /** One Session chosen from search that must be exposed and scrolled into view. */ revealSessionId?: SessionId | undefined /** Acknowledge that the chosen Session row has been revealed. */ @@ -274,10 +260,11 @@ type SessionTreeProps = Pick< /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ - list, useSessionStatus, startSession, open, forkSession, workspaces, ungroupedSessionIds, + list, useSessionStatus, startSession, open, workspaces, ungroupedSessionIds, rowState, workspaceReady, animationResetKey, usePanelInfo, - onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, onSessionUnarchive, onSessionPin, + onRenameRequest, onDeleteRequest, onSessionRenameRequest, + renderSlot, insertWorkspaceBefore, nestWorkspaces, groupExpansion, setGroupExpanded, setSessionOrder, home, t, @@ -557,11 +544,8 @@ function SessionTree({ currentId={current} now={now} onOpen={open} - onRename={onSessionRename} - onFork={forkSession} - onArchive={onSessionArchive} - onUnarchive={onSessionUnarchive} - onPin={onSessionPin} + onRenameRequest={onSessionRenameRequest} + renderSlot={renderSlot} onReveal={node.id === revealSessionId && group.key === revealGroup ? () => { onSessionRevealed(node.id) } : undefined} @@ -610,19 +594,16 @@ function SessionTree({ /** The flat "In one list" body: every session is one draggable top-level row. */ function FlatList({ - list, sessionIds, rowState, useSessionStatus, open, forkSession, onSessionRename, onSessionArchive, - onSessionUnarchive, onSessionPin, + list, sessionIds, rowState, useSessionStatus, open, onSessionRenameRequest, + renderSlot, usePanelInfo, setSessionOrder, workspaceReady, animationResetKey, revealSessionId, onSessionRevealed, t, }: Pick< SessionTreeProps, | 'useSessionStatus' | 'open' - | 'forkSession' - | 'onSessionRename' - | 'onSessionArchive' - | 'onSessionUnarchive' - | 'onSessionPin' + | 'onSessionRenameRequest' + | 'renderSlot' | 'usePanelInfo' | 'setSessionOrder' | 'workspaceReady' @@ -678,11 +659,8 @@ function FlatList({ currentId={currentId} now={now} onOpen={open} - onRename={onSessionRename} - onFork={forkSession} - onArchive={onSessionArchive} - onUnarchive={onSessionUnarchive} - onPin={onSessionPin} + onRenameRequest={onSessionRenameRequest} + renderSlot={renderSlot} onReveal={node.id === revealSessionId ? () => { onSessionRevealed(node.id) } : undefined} @@ -834,15 +812,12 @@ export function WorkspaceBrowser({ actions, startSession, open, - renameSession, - forkSession, + requestSessionRename, + notifyArchivedNotOpenable, renameWorkspace, deleteWorkspace, insertWorkspaceBefore, - archiveSession, unarchiveSession, - pinSession, - unpinSession, createWorkspace, searchSessions, searchResultLimit, @@ -869,23 +844,11 @@ export function WorkspaceBrowser({ const archivedFilter = useStore(s => s.archivedFilter ?? 'default') const groupExpansion = useStore(s => s.groupExpansion) const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount) - // Each bump opens the view-options menu (the archive toast's filter action). - const [viewOptionsOpenSeq, setViewOptionsOpenSeq] = useState(0) - // One transient banner at a time; the seq keys remounts so a repeat message restarts its hold. - const [toast, setToast] = useState<{ - text: string - seq: number - tone: 'warning' | 'success' - actions?: readonly { label: string; onClick: () => void }[] - } | null>(null) - const showToast = (text: string): void => { - setToast(current => ({ text, seq: (current?.seq ?? 0) + 1, tone: 'warning' })) - } // Archived sessions are not openable: the row stays visible under the // filter but a click explains instead of navigating. const guardedOpen = (sessionId: SessionId): void => { if (archivedSessionIds.includes(sessionId)) { - showToast(t('toast.archivedNotOpenable')) + notifyArchivedNotOpenable() return } open(sessionId) @@ -945,9 +908,6 @@ export function WorkspaceBrowser({ [UNGROUPED_KEY, orderedUngroupedSessionIds] as const, [FLAT_SESSION_ORDER_KEY, orderedFlatSessionIds] as const, ]), [orderedFlatSessionIds, orderedUngroupedSessionIds, orderedWorkspaces]) - const pinOrderSource = { workspaces, ungroupedMemberIds, flatMemberIds, summaries: list.byId, rowState: orderState } - const latestPinOrder = useRef(pinOrderSource) - latestPinOrder.current = pinOrderSource useEffect(() => { if (workspacePhase !== 'ready') return actions.retainAccountKeys([ @@ -1017,7 +977,7 @@ export function WorkspaceBrowser({ const openSearchResult = (sessionId: SessionId): void => { if (archivedSessionIds.includes(sessionId)) { - showToast(t('toast.archivedNotOpenable')) + notifyArchivedNotOpenable() return } setRevealSessionId(sessionId) @@ -1132,88 +1092,13 @@ export function WorkspaceBrowser({ }) } - // Session rename dialog (same browser-owned pattern as workspace rename; - // sessions have no client-side name-conflict rule — the host normalizes). - // Unlike workspace rename, an unchanged title is NOT blocked: confirming - // the current automatic title is the gesture that pins it. - const [sessionRenameTarget, setSessionRenameTarget] = useState<{ sessionId: SessionNode['id']; currentTitle: string } | null>(null) - const [sessionRenameDraft, setSessionRenameDraft] = useState('') - const [sessionRenaming, setSessionRenaming] = useState(false) - const [sessionRenameError, setSessionRenameError] = useState(null) - const sessionRenameTrimmed = sessionRenameDraft.trim() - const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' || sessionRenameTarget === null - const closeSessionRename = () => { - if (sessionRenaming) return - setSessionRenameTarget(null) - setSessionRenameError(null) - } - const confirmSessionRename = () => { - if (sessionRenameBlocked) return - setSessionRenaming(true) - setSessionRenameError(null) - renameSession(sessionRenameTarget.sessionId, sessionRenameTrimmed).then(() => { - setSessionRenaming(false) - setSessionRenameTarget(null) - }).catch((reason: unknown) => { - setSessionRenaming(false) - setSessionRenameError(reason instanceof Error ? reason.message : String(reason)) - }) - } - const onSessionRename = (sessionId: SessionNode['id'], currentTitle: string) => { - setSessionRenameTarget({ sessionId, currentTitle }) - setSessionRenameDraft(currentTitle) - setSessionRenameError(null) - } - - // Archive preserves the log and account position, so it needs no confirmation. - // The selected filter determines visibility after the archive-set echo. - const onSessionArchive = (sessionId: SessionNode['id']) => { - archiveSession(sessionId).then(() => { - setToast(current => ({ - text: t('toast.archived'), - seq: (current?.seq ?? 0) + 1, - tone: 'success', - actions: [ - { label: t('toast.archivedUndo'), onClick: () => { setToast(null); onSessionUnarchive(sessionId) } }, - { - prefix: t('toast.archivedOr'), - label: t('toast.archivedFilter'), - onClick: () => { setToast(null); setViewOptionsOpenSeq(seq => seq + 1) }, - }, - ], - })) - }).catch((reason: unknown) => { - console.warn('session archive rejected:', reason) - }) - } + // The search results' restore button; the row actions own the rest of the + // Session verbs as slot entries. const onSessionUnarchive = (sessionId: SessionNode['id']) => { unarchiveSession(sessionId).catch((reason: unknown) => { console.warn('session unarchive rejected:', reason) }) } - // Pin failures surface as a toast: unlike archive, nothing else on the - // surface moves, so a silent failure would read as a dead menu action. - const onSessionPin = (sessionId: SessionNode['id'], pin: boolean) => { - (pin ? pinSession(sessionId) : unpinSession(sessionId)).then(() => { - if (!pin) return - const source = latestPinOrder.current - actions.pinSessionOrder( - sessionId, - [owningGroupKey(source.workspaces, sessionId), FLAT_SESSION_ORDER_KEY], - { - members: Object.fromEntries([ - ...source.workspaces.map(workspace => [workspace.workspaceId, workspace.sessionIds] as const), - [UNGROUPED_KEY, source.ungroupedMemberIds], - [FLAT_SESSION_ORDER_KEY, source.flatMemberIds], - ]), - summaries: source.summaries, - rowState: source.rowState, - }, - ) - }).catch(() => { - showToast(t(pin ? 'toast.pinFailed' : 'toast.unpinFailed')) - }) - } // Delete dialog is separate from the row so a successful removal can // unmount that row without tearing down the in-flight confirmation state. @@ -1324,7 +1209,6 @@ export function WorkspaceBrowser({ onGroupPick={actions.setGroupBy} onOrderPick={(mode) => { actions.setOrderBy(mode, activeSessionOrders) }} onArchivedFilterPick={actions.setArchivedFilter} - openSeq={viewOptionsOpenSeq} t={t} /> )} @@ -1414,9 +1298,9 @@ export function WorkspaceBrowser({ workspaceReady={workspaceReady} animationResetKey={`${groupBy}/${orderBy}/${archivedFilter}`} useSessionStatus={useSessionStatus} - open={guardedOpen} forkSession={forkSession} - onSessionRename={onSessionRename} onSessionArchive={onSessionArchive} - onSessionUnarchive={onSessionUnarchive} onSessionPin={onSessionPin} + open={guardedOpen} + onSessionRenameRequest={requestSessionRename} + renderSlot={renderSlot} setSessionOrder={saveSessionOrder} revealSessionId={revealSessionId} onSessionRevealed={acknowledgeSessionReveal} @@ -1428,11 +1312,8 @@ export function WorkspaceBrowser({ usePanelInfo={usePanelInfo} list={list} useSessionStatus={useSessionStatus} - onSessionRename={onSessionRename} - onSessionArchive={onSessionArchive} - onSessionUnarchive={onSessionUnarchive} - onSessionPin={onSessionPin} - forkSession={forkSession} + onSessionRenameRequest={requestSessionRename} + renderSlot={renderSlot} workspaces={orderedWorkspaces} ungroupedSessionIds={orderedUngroupedSessionIds} workspaceReady={workspaceReady} @@ -1497,37 +1378,6 @@ export function WorkspaceBrowser({ {renameError !== null &&
{renameError}
} - - - - - )} - > - { e.target.select() }} - onChange={(e) => { setSessionRenameDraft(e.target.value); setSessionRenameError(null) }} - onCompositionStart={() => { composingRef.current = true }} - onCompositionEnd={() => { composingRef.current = false }} - onKeyDown={(e) => { - if (e.key === 'Enter' && !composingRef.current) { - e.preventDefault() - confirmSessionRename() - } - }} - /> - {sessionRenameError !== null &&
{sessionRenameError}
} -
{t('delete.pending')}
} {deleteError !== null &&
{deleteError}
} - {toast !== null && ( - }} - {...toast.actions === undefined ? {} : { actions: toast.actions, holdMs: ARCHIVE_TOAST_HOLD_MS }} - onDone={() => { setToast(null) }} - /> - )} ) } diff --git a/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx b/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx new file mode 100644 index 0000000000..0555303425 --- /dev/null +++ b/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx @@ -0,0 +1,58 @@ +/** + * The archive action: a `sidebar.workspaces.session.menu.item` row and a + * `sidebar.workspaces.session.row.action` button over one injected behavior. + * The same entries restore an archived row; the notice a successful archive + * raises and the diagnostics for Host rejections live in the injected + * callbacks, not here. + */ +import { + IconArchiveOutlineRegular, IconUnarchiveOutlineRegular, MenuItemButton, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { ArchiveSessionInjected, SessionMenuItemProps, SessionRowActionProps } from '../contract/slots.ts' +import css from '../rows/Rows.module.css' + +/** + * Menu row (order 400): archive, or restore an archived row. + * @param props - owner share, the archive share, and the menu open state. + * @returns the row. + */ +export function ArchiveSessionMenuItem({ + sessionId, useArchived, useMenuOpenState, archiveSession, unarchiveSession, t, +}: SessionMenuItemProps) { + const [, setMenuOpen] = useMenuOpenState() + const archived = useArchived(set => set.has(sessionId)) + return ( + : } + onSelect={() => { + setMenuOpen(false) + ;(archived ? unarchiveSession : archiveSession)(sessionId) + }} + > + {t(archived ? 'menu.unarchiveSession' : 'menu.archiveSession')} + + ) +} + +/** + * Hover button (order 100): archive, or restore an archived row. + * @param props - owner share and the archive share. + * @returns the button. + */ +export function ArchiveSessionRowButton({ + sessionId, useArchived, archiveSession, unarchiveSession, t, +}: SessionRowActionProps) { + const archived = useArchived(set => set.has(sessionId)) + return ( + + + + ) +} diff --git a/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx b/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx new file mode 100644 index 0000000000..1b979b29de --- /dev/null +++ b/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx @@ -0,0 +1,24 @@ +/** The fork action: one `sidebar.workspaces.session.menu.item` row. */ +import { IconBranchOutlineRegular, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ForkSessionInjected, SessionMenuItemProps } from '../contract/slots.ts' + +/** + * Menu row (order 300): fork at the Session's last completed turn; the child + * arrives through the Host list beside its source. + * @param props - owner share, menu open state, and the fork share. + * @returns the row. + */ +export function ForkSessionMenuItem({ sessionId, useMenuOpenState, forkSession, t }: SessionMenuItemProps) { + const [, setMenuOpen] = useMenuOpenState() + return ( + } + onSelect={() => { + setMenuOpen(false) + forkSession(sessionId) + }} + > + {t('menu.fork')} + + ) +} diff --git a/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx b/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx new file mode 100644 index 0000000000..00ce1bff80 --- /dev/null +++ b/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx @@ -0,0 +1,69 @@ +/** + * The pin action: a `sidebar.workspaces.session.menu.item` row and a + * `sidebar.workspaces.session.row.action` button over one injected behavior. + * Pin and archive are mutually exclusive on the Host, so the action reads both + * sets and does not offer itself on an archived row; what a pin does beyond + * the Host call (fronting the Session in its saved orders, the failure + * notice) lives in the injected callbacks, not here. + */ +import { + IconPinFillRegular, IconPinOutlineRegular, MenuItemButton, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PinSessionInjected, SessionMenuItemProps, SessionRowActionProps } from '../contract/slots.ts' +import css from '../rows/Rows.module.css' + +type PinState = Pick, 'sessionId' | 'usePinned' | 'useArchived'> + +/** The row's pin and archive membership, one Set lookup each. */ +function usePinState({ sessionId, usePinned, useArchived }: PinState) { + return { + pinned: usePinned(pinned => pinned.has(sessionId)), + archived: useArchived(archived => archived.has(sessionId)), + } +} + +/** + * Menu row (order 100): pin or unpin by the row's current state; absent on archived rows. + * @param props - owner share, the pin share, and the menu open state. + * @returns the row, or null for an archived Session. + */ +export function PinSessionMenuItem(props: SessionMenuItemProps) { + const { sessionId, useMenuOpenState, pinSession, unpinSession, t } = props + const [, setMenuOpen] = useMenuOpenState() + const { pinned, archived } = usePinState(props) + if (archived) return null + return ( + : } + onSelect={() => { + setMenuOpen(false) + ;(pinned ? unpinSession : pinSession)(sessionId) + }} + > + {t(pinned ? 'menu.unpinSession' : 'menu.pinSession')} + + ) +} + +/** + * Hover button (order 200, rightmost: it lands where the rest-state pin marker sits); absent on archived rows. + * @param props - owner share and the pin share. + * @returns the button, or null for an archived Session. + */ +export function PinSessionRowButton(props: SessionRowActionProps) { + const { sessionId, pinSession, unpinSession, t } = props + const { pinned, archived } = usePinState(props) + if (archived) return null + return ( + + + + ) +} diff --git a/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx b/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx new file mode 100644 index 0000000000..5231e020bd --- /dev/null +++ b/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx @@ -0,0 +1,122 @@ +/** + * The rename action: a `sidebar.workspaces.session.menu.item` row that raises + * the rename request, and the `shell.overlay` dialog entry that answers it. + * The dialog lives outside the row menu because the row unmounts with the + * menu; the browser raises the same request from a title double-click. + */ +import { useRef, useState } from 'react' +import { Button, IconEditOutlineRegular, MenuItemButton, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import type { + RenameSessionInjected, SessionMenuItemProps, SessionRenameDialogInjected, SessionRenameDialogProps, SessionRenameTarget, +} from '../contract/slots.ts' +import css from '../rows/WorkspaceBrowser.module.css' + +/** + * Menu row (order 200): ask for the rename dialog, seeded with the row's current title. + * @param props - owner share, menu open state, and the rename share. + * @returns the row. + */ +export function RenameSessionMenuItem({ + sessionId, displayTitle, useMenuOpenState, requestSessionRename, t, +}: SessionMenuItemProps) { + const [, setMenuOpen] = useMenuOpenState() + return ( + } + onSelect={() => { + setMenuOpen(false) + requestSessionRename(sessionId, displayTitle) + }} + > + {t('rename')} + + ) +} + +/** + * The `shell.overlay` entry: nothing while no rename is requested, otherwise + * one dialog per request (keyed by the Session, so a new request starts a + * fresh draft). Sessions have no client-side name-conflict rule (the host + * normalizes), and unlike Workspace rename an unchanged title is NOT + * blocked: confirming the current automatic title is the gesture that pins it. + * @param props - the request hook, its settlement, the rename hop, and the locale seat. + * @returns the open dialog, or null. + */ +export function SessionRenameDialog({ useRenameRequest, settleSessionRename, renameSession, t }: SessionRenameDialogProps) { + const request = useRenameRequest(pending => pending) + if (request === null) return null + return ( + + ) +} + +/** One request's dialog: the draft seeds from the request on mount; in-flight and error state die with it. */ +function RenameForm({ request, renameSession, onSettle, t }: { + request: SessionRenameTarget + renameSession: SessionRenameDialogInjected['renameSession'] + onSettle: () => void + t: SessionRenameDialogProps['t'] +}) { + const [draft, setDraft] = useState(request.currentTitle) + const [renaming, setRenaming] = useState(false) + const [error, setError] = useState(null) + // IME composition: Enter that commits a composition must not submit. + const composingRef = useRef(false) + const trimmed = draft.trim() + const blocked = renaming || trimmed === '' + const close = () => { + if (renaming) return + onSettle() + } + const confirm = () => { + if (blocked) return + setRenaming(true) + setError(null) + renameSession(request.sessionId, trimmed).then(() => { + setRenaming(false) + onSettle() + }).catch((reason: unknown) => { + setRenaming(false) + setError(reason instanceof Error ? reason.message : String(reason)) + }) + } + return ( + + + + + )} + > + { e.target.select() }} + onChange={(e) => { setDraft(e.target.value); setError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !composingRef.current) { + e.preventDefault() + confirm() + } + }} + /> + {error !== null &&
{error}
} +
+ ) +} diff --git a/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx b/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx new file mode 100644 index 0000000000..f000fc4b4a --- /dev/null +++ b/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx @@ -0,0 +1,50 @@ +/** + * The `shell.overlay` entry that shows the row actions' notices: the archived + * notice with its undo and "show archived" actions, pin failures, and the + * browser's "archived rows cannot be opened" notice. One notice at a time; + * a parent rerender does not extend its hold. + */ +import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { RowToastProps } from '../contract/slots.ts' + +/** Hold for the actionable archived notice: two buttons need a longer read-and-react window than a plain notice. */ +const ARCHIVED_TOAST_HOLD_MS = 6000 + +/** + * Render the row actions' current notice: the archived notice with its undo + * and show-archived actions on a 6 s hold, or a plain warning for a failed + * pin or an archived row that was clicked. + * @param props - the notice hook, its dismissal, the two archived-notice actions, and the locale seat. + * @returns the notice on display, or null. + */ +export function RowActionToast({ useToast, dismissToast, undoArchive, showArchived, t }: RowToastProps) { + const toast = useToast(current => current) + if (toast === null) return null + if (toast.kind === 'archived') { + const { sessionId } = toast + return ( + { dismissToast(); undoArchive(sessionId) } }, + { prefix: t('toast.archivedOr'), label: t('toast.archivedFilter'), onClick: () => { dismissToast(); showArchived() } }, + ]} + onDone={dismissToast} + /> + ) + } + const text = toast.kind === 'pinFailed' + ? t('toast.pinFailed') + : toast.kind === 'unpinFailed' ? t('toast.unpinFailed') : t('toast.archivedNotOpenable') + return ( + } + onDone={dismissToast} + /> + ) +} diff --git a/packages/client/ui-workspace/src/client/session-actions/derived.ts b/packages/client/ui-workspace/src/client/session-actions/derived.ts new file mode 100644 index 0000000000..900fc8f053 --- /dev/null +++ b/packages/client/ui-workspace/src/client/session-actions/derived.ts @@ -0,0 +1,26 @@ +/** Derived observable sources for the row actions' injected hooks. */ +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' + +/** + * Project one observable into another, recomputing only when the source + * snapshot changes identity, so consumers that select from the projection + * (a Set lookup per row) never rebuild it per read. + * @param source - the observable to project. + * @param project - pure projection of one source snapshot. + * @returns the projected observable, subscribing through the source. + */ +export function derive(source: HostObservable, project: (snapshot: S) => T): HostObservable { + let seen: S | undefined + let value: T | undefined + return { + getSnapshot: () => { + const snapshot = source.getSnapshot() + if (value === undefined || snapshot !== seen) { + seen = snapshot + value = project(snapshot) + } + return value + }, + subscribe: listener => source.subscribe(listener), + } +} diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index 3ccc377e77..bf32225b88 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -128,3 +128,6 @@ export function createWorkspaceViewStore(): EngineStoreHandle['create']>['actions'] diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 6950c15a4e..7bf2036fe9 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -163,11 +163,12 @@ export function orderByRecency( /** * Reconcile a browser-local manual order with current account membership. + * New ordinary forks precede their sources without changing saved entries' relative order. * @param memberIds - authoritative account membership. * @param savedOrder - previously saved browser-local order. - * @param summaries - current Session summaries used to append newly known members by recency. + * @param summaries - current Session metadata; unknown new members wait for their summaries. * @param rowState - global pin and archive membership; only account members can supplement the order. - * @returns missing pins, retained saved slots, new ordinary members, then missing archives; unknown new members wait for their summaries. + * @returns saved relative positions plus missing members ordered by pin, fork source, recency, and archive status. */ export function reconcileManualOrder( memberIds: readonly SessionId[], @@ -198,7 +199,18 @@ export function reconcileManualOrder( if (archived.has(id)) archives.push(id) else ordinary.push(id) } - return [...pins, ...ordered, ...ordinary, ...archives] + const result = [...pins, ...ordered, ...ordinary, ...archives] + const pending = new Set(ordinary) + const placeFork = (id: SessionId): void => { + if (!pending.delete(id)) return + const parentId = summaries[id]?.parentId + if (parentId === undefined || parentId === id || !result.includes(parentId)) return + placeFork(parentId) + result.splice(result.indexOf(id), 1) + result.splice(result.indexOf(parentId), 0, id) + } + for (const id of [...ordinary].reverse()) placeFork(id) + return result } /** diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 507dadb724..704027f216 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -1,14 +1,52 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' +import type { + SessionListState, SessionReference, SessionSummary, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { + type ArchiveSessionInjected, type ForkSessionInjected, menuOpenStateFactory, type PinSessionInjected, + type RenameSessionInjected, type RowToastInjected, type SessionRenameDialogInjected, type WorkspaceViewStoreHandle, +} from '../src/client/contract/slots.ts' import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' +import { ArchiveSessionMenuItem, ArchiveSessionRowButton } from '../src/client/session-actions/ArchiveSession.tsx' +import { ForkSessionMenuItem } from '../src/client/session-actions/ForkSession.tsx' +import { PinSessionMenuItem, PinSessionRowButton } from '../src/client/session-actions/PinSession.tsx' +import { RenameSessionMenuItem, SessionRenameDialog } from '../src/client/session-actions/RenameSession.tsx' +import { RowActionToast } from '../src/client/session-actions/RowActionToast.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' +import { FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' +import { UNGROUPED_KEY } from '../src/client/tree.ts' import { apply as hostApply } from '../src/index.ts' -import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' + +const sid = (id: string) => id as SessionId +const summary = (id: string, updatedAt: number): SessionSummary => ({ + id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, retainedBy: {}, +}) +const sessionState = (items: readonly SessionSummary[]): SessionListState => ({ + ids: items.map(item => item.id), + byId: Object.fromEntries(items.map(item => [item.id, item])), + phase: 'ready', + projectionsBySession: {}, jobsBySession: {}, +}) +const workspace = (id: string, sessionIds: readonly string[]): WorkspaceView => ({ + workspaceId: id as WorkspaceId, path: `/projects/${id}`, title: id, + sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +}) +const workspaceState = ( + items: readonly WorkspaceView[], + archivedSessionIds: readonly SessionId[] = [], + pinnedSessionIds: readonly SessionId[] = [], +): WorkspaceSnapshot => ({ + items, archivedSessionIds, pinnedSessionIds, state: 'idle', phase: 'ready', error: null, +}) async function bench() { const ctx = new Context() @@ -44,29 +82,29 @@ async function bench() { operation: (reference: SessionReference) => unknown, ) => await operation(retain(target))) const fork = vi.fn(async () => 'forked' as never) + const pinSession = vi.fn(async () => undefined) + const unpinSession = vi.fn(async () => undefined) + // The Host snapshots the injected hooks derive from and a pin's order write + // reads at completion; a test replaces them whole, so an unchanged snapshot + // keeps its identity between reads. + let workspaceSnapshot = workspaceState([]) + let sessionSnapshot = sessionState([]) const subscribe = () => () => {} + const workspacesSubscribe = vi.fn(subscribe) ctx.provide('workspaces', { - list: { - getSnapshot: () => ({ - items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, - }), - subscribe, - }, + list: { getSnapshot: () => workspaceSnapshot, subscribe: workspacesSubscribe }, create, rename, delete: vi.fn(async () => undefined), insertBefore: vi.fn(async () => undefined), archiveSession: vi.fn(async () => undefined), + unarchiveSession: vi.fn(async () => undefined), + pinSession, + unpinSession, insertSessionBefore: vi.fn(async () => ({})), } as never) ctx.provide('sessions', { - list: { - getSnapshot: () => ({ - ids: [], byId: {}, phase: 'ready', - projectionsBySession: {}, jobsBySession: {}, - }), - subscribe, - }, + list: { getSnapshot: () => sessionSnapshot, subscribe }, create: vi.fn(async () => 'created' as never), retain, using, @@ -89,18 +127,50 @@ async function bench() { ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, - retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, + retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession, + workspacesSubscribe, + setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot }, + setSessions: (snapshot: SessionListState): void => { sessionSnapshot = snapshot }, } } -type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' +type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' | 'shell.overlay' -/** Declare any subset of the holes with a single root registration ('root' is a single slot). */ +const MENU_ITEM = 'sidebar.workspaces.session.menu.item' +const ROW_ACTION = 'sidebar.workspaces.session.row.action' +type RowListName = typeof MENU_ITEM | typeof ROW_ACTION | 'shell.overlay' + +/** Declare any subset of the holes with a single root registration ('root' is a single slot); the overlay is a list. */ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { - const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }])) + const children = Object.fromEntries(names.map(name => [ + name, { kind: name === 'shell.overlay' ? 'list' : 'single', scope: 'root' }, + ])) return slots.register({ name: 'root', children } as never, () => null) } +/** The one entry registered under `id` in a list. */ +function entry(slots: SlotRegistry, key: RowListName, id: string): StoredEntry { + const found = slots.entries(key).find(candidate => candidate.options.id === id) + if (found === undefined) throw new Error(`no ${key} entry ${id}`) + return found +} + +/** An entry's injected face; the shipped factories ignore the positional store actions. */ +function faceOf(registration: StoredEntry): object { + if (registration.inject === undefined) throw new Error(`entry ${String(registration.options.id)} declares no inject face`) + return registration.inject() +} + +/** The viewing-store instance the browser's declared handle hands the renderer. */ +function viewInstance(slots: SlotRegistry) { + const browser = slots.entries('sidebar.workspaces')[0]! + if (browser.store === undefined) throw new Error('the browser entry declares no viewing store') + return (browser.store as WorkspaceViewStoreHandle).create() +} + +/** Let the injected callbacks' Host-call chains settle: one macrotask drains every pending microtask. */ +const settled = (): Promise => new Promise((resolve) => { setTimeout(resolve, 0) }) + describe('ui-workspace apply', () => { it('keeps the host Loader entry inert', () => { expect(hostApply).not.toThrow() @@ -124,10 +194,268 @@ describe('ui-workspace apply', () => { const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() - declare(after.slots, 'conversation.hero.workspace', 'conversation.empty.workspace') + declare(after.slots, 'sidebar.workspaces', 'conversation.hero.workspace', 'conversation.empty.workspace', 'shell.overlay') await Promise.resolve() expect(after.slots.entries('conversation.hero.workspace')[0]!.component).toBe(WorkspacePicker) - // expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker) + // The row actions follow the browser's own declaration, whenever it lands. + expect(after.slots.entries(MENU_ITEM)).toHaveLength(4) + expect(after.slots.entries(ROW_ACTION)).toHaveLength(2) + expect(after.slots.entries('shell.overlay')).toHaveLength(2) + }) + + it('declares the two Session row lists and registers the shipped actions and overlay surfaces into them', async () => { + const b = await bench() + declare(b.slots, 'sidebar.workspaces', 'shell.overlay') + await b.ctx.plugin({ inject: [...inject], apply }).await() + // The menu list binds the row's open state into every entry's hook; the + // hover-button list carries no common face. + expect(b.slots.spec(MENU_ITEM)).toEqual({ + kind: 'list', scope: 'root', inject: { hooks: { menuOpenState: menuOpenStateFactory } }, + }) + expect(b.slots.spec(ROW_ACTION)).toEqual({ kind: 'list', scope: 'root' }) + + const rows = (key: RowListName) => b.slots.entries(key) + .map(registration => [registration.options.id, registration.options.order, registration.component, registration.locale]) + expect(rows(MENU_ITEM)).toEqual([ + ['pin', 100, PinSessionMenuItem, 'workspace'], + ['rename', 200, RenameSessionMenuItem, 'workspace'], + ['fork', 300, ForkSessionMenuItem, 'workspace'], + ['archive', 400, ArchiveSessionMenuItem, 'workspace'], + ]) + expect(rows(ROW_ACTION)).toEqual([ + ['archive', 100, ArchiveSessionRowButton, 'workspace'], + ['pin', 200, PinSessionRowButton, 'workspace'], + ]) + expect(rows('shell.overlay')).toEqual([ + ['workspace.session-rename', undefined, SessionRenameDialog, 'workspace'], + ['workspace.row-toast', undefined, RowActionToast, 'workspace'], + ]) + // Only the browser declares the viewing store; its handle hands out the + // one instance the injected callbacks write view state through. The + // row actions and overlay surfaces declare none. + const browser = b.slots.entries('sidebar.workspaces')[0]! + expect(browser.store).toBeDefined() + expect(viewInstance(b.slots)).toBe(viewInstance(b.slots)) + for (const registration of [...b.slots.entries(MENU_ITEM), ...b.slots.entries(ROW_ACTION), ...b.slots.entries('shell.overlay')]) { + expect(registration.store).toBeUndefined() + } + // Each share raises its own notices inside its callbacks. + for (const id of ['pin', 'archive']) { + expect(faceOf(entry(b.slots, MENU_ITEM, id))).not.toHaveProperty('notify') + expect(faceOf(entry(b.slots, ROW_ACTION, id))).not.toHaveProperty('notify') + } + }) + + it('derives the pinned and archived Sets from the Workspace snapshot, rebuilt only when it changes', async () => { + const b = await bench() + b.setWorkspaces(workspaceState([workspace('alpha', ['one', 'two'])], [sid('two')], [sid('one')])) + declare(b.slots, 'sidebar.workspaces') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const pin = faceOf(entry(b.slots, MENU_ITEM, 'pin')) as PinSessionInjected + const pinButton = faceOf(entry(b.slots, ROW_ACTION, 'pin')) as PinSessionInjected + const archive = faceOf(entry(b.slots, ROW_ACTION, 'archive')) as ArchiveSessionInjected + const pinned = pin.hooks.pinned.getSnapshot() + const archived = pin.hooks.archived.getSnapshot() + expect(pinned).toEqual(new Set(['one'])) + expect(archived).toEqual(new Set(['two'])) + // One source per set, shared by every entry and held while the snapshot stands. + expect(pin.hooks.pinned.getSnapshot()).toBe(pinned) + expect(pinButton.hooks.pinned.getSnapshot()).toBe(pinned) + expect(archive.hooks.archived.getSnapshot()).toBe(archived) + // Subscriptions ride the Workspace Controller's list source. + const listener = vi.fn() + const unsubscribe = pin.hooks.pinned.subscribe(listener) + expect(b.workspacesSubscribe).toHaveBeenCalledWith(listener) + unsubscribe() + + b.setWorkspaces(workspaceState([workspace('alpha', ['one', 'two'])], [], [sid('one'), sid('two')])) + expect(pin.hooks.pinned.getSnapshot()).toEqual(new Set(['one', 'two'])) + expect(pin.hooks.pinned.getSnapshot()).not.toBe(pinned) + expect(archive.hooks.archived.getSnapshot()).toEqual(new Set()) + }) + + it('pins through the navigation service, which fronts the Session in its group and the flat list of the browser view', async () => { + const b = await bench() + b.setWorkspaces(workspaceState([workspace('alpha', ['one', 'two', 'three'])])) + b.setSessions(sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])) + declare(b.slots, 'sidebar.workspaces', 'shell.overlay') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const view = viewInstance(b.slots) + const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected + expect(view.getSnapshot().sessionOrderByAccount).toEqual({}) + + const pin = faceOf(entry(b.slots, MENU_ITEM, 'pin')) as PinSessionInjected + pin.pinSession(sid('three')) + expect(b.pinSession).toHaveBeenCalledWith('three') + await vi.waitFor(() => { + expect(view.getSnapshot().sessionOrderByAccount).toEqual({ + alpha: ['three', 'one', 'two'], + [UNGROUPED_KEY]: [], + [FLAT_SESSION_ORDER_KEY]: ['three', 'one', 'two'], + }) + }) + // The saved positions lead without leaving Last updated, and a successful pin raises no notice. + expect(view.getSnapshot().orderBy).toBe('updated') + expect(toast.hooks.toast.getSnapshot()).toBeNull() + + // The hover button's face is the same behavior. + const pinButton = faceOf(entry(b.slots, ROW_ACTION, 'pin')) as PinSessionInjected + pinButton.pinSession(sid('one')) + expect(b.pinSession).toHaveBeenLastCalledWith('one') + await vi.waitFor(() => { + expect(view.getSnapshot().sessionOrderByAccount.alpha).toEqual(['one', 'three', 'two']) + }) + + // Unpin leaves the saved orders as they are. + const before = view.getSnapshot().sessionOrderByAccount + pin.unpinSession(sid('one')) + expect(b.unpinSession).toHaveBeenCalledWith('one') + await settled() + expect(view.getSnapshot().sessionOrderByAccount).toBe(before) + expect(toast.hooks.toast.getSnapshot()).toBeNull() + }) + + it('raises the pin and unpin failure notices and writes no order', async () => { + const b = await bench() + b.setWorkspaces(workspaceState([workspace('alpha', ['one'])])) + b.setSessions(sessionState([summary('one', 1)])) + declare(b.slots, 'sidebar.workspaces', 'shell.overlay') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const view = viewInstance(b.slots) + const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected + const pin = faceOf(entry(b.slots, ROW_ACTION, 'pin')) as PinSessionInjected + + b.pinSession.mockRejectedValueOnce(new Error('pin wire down')) + pin.pinSession(sid('one')) + await vi.waitFor(() => { expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'pinFailed', seq: 1 }) }) + b.unpinSession.mockRejectedValueOnce(new Error('unpin wire down')) + pin.unpinSession(sid('one')) + await vi.waitFor(() => { expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'unpinFailed', seq: 2 }) }) + expect(view.getSnapshot().sessionOrderByAccount).toEqual({}) + }) + + it('archives through the navigation service and raises the archived notice; Host rejections are console diagnostics', async () => { + const b = await bench() + declare(b.slots, 'sidebar.workspaces', 'shell.overlay') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const archiveSession = vi.spyOn(b.ctx.uiWorkspace, 'archiveSession').mockResolvedValue(undefined) + const unarchiveSession = vi.spyOn(b.ctx.uiWorkspace, 'unarchiveSession').mockResolvedValue(undefined) + const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected + const archive = faceOf(entry(b.slots, ROW_ACTION, 'archive')) as ArchiveSessionInjected + + archive.archiveSession(sid('one')) + expect(archiveSession).toHaveBeenCalledWith('one') + await vi.waitFor(() => { + expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'archived', sessionId: 'one', seq: 1 }) + }) + // The menu row's face is the same behavior; a restore raises no notice. + const archiveRow = faceOf(entry(b.slots, MENU_ITEM, 'archive')) as ArchiveSessionInjected + archiveRow.unarchiveSession(sid('one')) + expect(unarchiveSession).toHaveBeenCalledWith('one') + await settled() + expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'archived', sessionId: 'one', seq: 1 }) + + const archiveRejection = new Error('archive exploded') + const unarchiveRejection = new Error('unarchive exploded') + archiveSession.mockRejectedValueOnce(archiveRejection) + unarchiveSession.mockRejectedValueOnce(unarchiveRejection) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + archive.archiveSession(sid('two')) + await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('session archive rejected:', archiveRejection) }) + archive.unarchiveSession(sid('two')) + await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('session unarchive rejected:', unarchiveRejection) }) + } finally { + warn.mockRestore() + } + // A rejected archive raises no notice. + expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'archived', sessionId: 'one', seq: 1 }) + }) + + it('the notice share takes the notice down, undoes an archive, and shows the archived rows', async () => { + const b = await bench() + declare(b.slots, 'sidebar.workspaces', 'shell.overlay') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const unarchiveSession = vi.spyOn(b.ctx.uiWorkspace, 'unarchiveSession').mockResolvedValue(undefined) + const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected + const browser = faceOf(b.slots.entries('sidebar.workspaces')[0]!) as WorkspaceBrowserInjected + const view = viewInstance(b.slots) + + // The browser raises the not-openable notice into the same entry, and the source notifies. + expect(toast.hooks.toast.getSnapshot()).toBeNull() + const noticed = vi.fn() + const unsubscribe = toast.hooks.toast.subscribe(noticed) + browser.notifyArchivedNotOpenable() + expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'archivedNotOpenable', seq: 1 }) + expect(noticed).toHaveBeenCalledOnce() + toast.dismissToast() + expect(toast.hooks.toast.getSnapshot()).toBeNull() + unsubscribe() + + expect(view.getSnapshot().archivedFilter).toBe('default') + toast.showArchived() + expect(view.getSnapshot().archivedFilter).toBe('show') + + toast.undoArchive(sid('one')) + expect(unarchiveSession).toHaveBeenCalledWith('one') + // A rejected undo is a console diagnostic. + const rejection = new Error('unarchive exploded') + unarchiveSession.mockRejectedValueOnce(rejection) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + toast.undoArchive(sid('two')) + await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('session unarchive rejected:', rejection) }) + } finally { + warn.mockRestore() + } + }) + + it('routes fork and rename through their shares, the rename dialog, and the browser face', async () => { + const b = await bench() + declare(b.slots, 'sidebar.workspaces', 'shell.overlay') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const unarchiveSession = vi.spyOn(b.ctx.uiWorkspace, 'unarchiveSession').mockResolvedValue(undefined) + + // Fork goes through the navigation service, which leaves the child unselected. + const forkSession = vi.spyOn(b.ctx.uiWorkspace, 'forkSession') + const fork = faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected + b.retain.mockClear() + fork.forkSession('session' as never) + await forkSession.mock.results[0]!.value + expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true }) + expect(b.retain).not.toHaveBeenCalled() + + // The rename row raises the request the dialog entry reads; settling clears it. + const rename = faceOf(entry(b.slots, MENU_ITEM, 'rename')) as RenameSessionInjected + const dialog = faceOf(entry(b.slots, 'shell.overlay', 'workspace.session-rename')) as SessionRenameDialogInjected + expect(dialog.hooks.renameRequest.getSnapshot()).toBeNull() + const requested = vi.fn() + const unsubscribeRequest = dialog.hooks.renameRequest.subscribe(requested) + rename.requestSessionRename('session' as never, 'Old title') + expect(dialog.hooks.renameRequest.getSnapshot()).toEqual({ sessionId: 'session', currentTitle: 'Old title' }) + expect(requested).toHaveBeenCalled() + unsubscribeRequest() + dialog.settleSessionRename() + expect(dialog.hooks.renameRequest.getSnapshot()).toBeNull() + // The dialog's rename hop reaches the Session face and rethrows a business error. + await dialog.renameSession('session' as never, 'renamed session') + expect(b.using).toHaveBeenCalledWith('session', { source: 'workspaceOperation' }, expect.any(Function)) + expect(b.renameSession).toHaveBeenCalledWith('renamed session') + b.renameSession.mockResolvedValueOnce({ + ok: false, error: new RemoteError('gateway/internal', 'title write failed', {}), + } as never) + await expect(dialog.renameSession('session' as never, 'again')).rejects.toThrow('title write failed') + + // The browser raises the same rename request from a title double-click, + // restores from its search results, and carries none of the row verbs itself. + const browser = faceOf(b.slots.entries('sidebar.workspaces')[0]!) as WorkspaceBrowserInjected + browser.requestSessionRename('session' as never, 'Row title') + expect(dialog.hooks.renameRequest.getSnapshot()).toEqual({ sessionId: 'session', currentTitle: 'Row title' }) + for (const verb of ['forkSession', 'archiveSession', 'pinSession', 'unpinSession', 'renameSession', 'undoArchive', 'showArchived']) { + expect(browser).not.toHaveProperty(verb) + } + await browser.unarchiveSession('session' as never) + expect(unarchiveSession).toHaveBeenCalledWith('session') }) it('routes browser actions and picker creation to the services', async () => { @@ -136,7 +464,7 @@ describe('ui-workspace apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() const startSession = vi.spyOn(b.ctx.uiWorkspace, 'startSession').mockImplementation(() => undefined) - const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() + const browser = faceOf(b.slots.entries('sidebar.workspaces')[0]!) as WorkspaceBrowserInjected // Both arms delegate to the shared Session navigation action. browser.startSession('ws' as never) expect(startSession).toHaveBeenCalledWith('ws') @@ -151,27 +479,17 @@ describe('ui-workspace apply', () => { }) expect(b.search).toHaveBeenCalledWith('match', signal) expect(browser.searchResultLimit).toBe(20) - await browser.renameSession('session' as never, 'renamed session') - expect(b.using).toHaveBeenCalledWith( - 'session', { source: 'workspaceOperation' }, expect.any(Function), - ) - expect(b.renameSession).toHaveBeenCalledWith('renamed session') - browser.forkSession('session' as never) - await vi.waitFor(() => { - expect(b.retain).toHaveBeenCalledWith('forked', { source: 'mainView' }) - }) - expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true }) await browser.renameWorkspace('ws' as never, 'renamed') expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') await browser.createWorkspace({ path: '/tmp/browser-project' }) expect(b.create).toHaveBeenCalledWith({ path: '/tmp/browser-project' }) - const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() + const picker = faceOf(b.slots.entries('conversation.hero.workspace')[0]!) as WorkspacePickerInjected await picker.createWorkspace({ path: '/tmp/project' }) expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) }) - it('declares the two directory-flow holes and reports their occupancy per surface', async () => { + it('declares the browser child slots and reports directory-flow occupancy per surface', async () => { const b = await bench() declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace') await b.ctx.plugin({ inject: [...inject], apply }).await() @@ -179,8 +497,8 @@ describe('ui-workspace apply', () => { expect(b.slots.spec('sidebar.workspaces.directoryFlow')).toMatchObject({ kind: 'single' }) expect(b.slots.spec('conversation.hero.workspace.directoryFlow')).toMatchObject({ kind: 'single' }) - const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() - const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() + const browser = faceOf(b.slots.entries('sidebar.workspaces')[0]!) as WorkspaceBrowserInjected + const picker = faceOf(b.slots.entries('conversation.hero.workspace')[0]!) as WorkspacePickerInjected expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) expect(browser.hooks.hostInfo.getSnapshot()).toMatchObject({ home: undefined }) expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) @@ -205,19 +523,27 @@ describe('ui-workspace apply', () => { }) 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)() + const browser = faceOf(b.slots.entries('sidebar.workspaces')[0]!) 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') + declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace', 'conversation.empty.workspace', 'shell.overlay') const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() + expect(b.slots.entries(MENU_ITEM)).toHaveLength(4) + expect(b.slots.entries(ROW_ACTION)).toHaveLength(2) + expect(b.slots.entries('shell.overlay')).toHaveLength(2) await fiber.dispose() expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0) expect(b.slots.entries('conversation.hero.workspace')).toHaveLength(0) - // expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0) + // The row lists collapse with the browser declaration; the overlay list + // (declared by the shell) keeps no ui-workspace entry behind. + expect(b.slots.spec(MENU_ITEM)).toBeUndefined() + expect(b.slots.entries(MENU_ITEM)).toHaveLength(0) + expect(b.slots.entries(ROW_ACTION)).toHaveLength(0) + expect(b.slots.entries('shell.overlay')).toHaveLength(0) }) }) diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index e8bcc71a79..3113616ada 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -1,24 +1,27 @@ // @vitest-environment jsdom /** * The session-rename assembly chain on SlotTestRuntime (real apply, real - * WorkspaceBrowser occupying the sidebar hole): row menu → rename dialog → - * the injected renameSession hop (sessions.binding → ISession.rename) → on - * the accepted unary response the dialog closes and the row re-labels from - * the list state — no push-frame wait. Coverage split: the assembled-app - * snapshot (apps/web/tests/session-actions.snapshot.ts) pins the full-app - * transcript; the - * verb's wire behavior stays with the Session Controller client package - * (session.spec.ts#rename), the dialog's own arms with rows.spec / - * workspace-browser.spec. + * WorkspaceBrowser occupying the sidebar hole, the shipped row actions and + * the overlay surfaces registered by the same apply): row menu → rename + * request → the `shell.overlay` dialog → the injected renameSession hop + * (sessions.binding → ISession.rename) → on the accepted unary response the + * dialog closes and the row re-labels from the list state — no push-frame + * wait. Coverage split: the real Loader/Web e2e + * (apps/web/tests/client-plugin-live.e2e.ts, with the ARIA golden under + * apps/web/tests/expected/client-plugin-live/) pins the full-app menu; + * the verb's wire behavior stays with the Session Controller client package + * (session.spec.ts#rename), the entries' own arms with + * session-actions.client.spec, the row's list rendering with rows.client.spec. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' +import { act, cleanup, fireEvent, waitFor, within } from '@testing-library/react' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' -import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { RemoteError, SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' // The service reads its initial locale from the browser; these specs assert @@ -45,13 +48,99 @@ async function createRuntime(): Promise { return runtime } -/** Test-owned sidebar shell role: declares and renders the browsing region. */ -type FrameProps = PropsRenderSlots<'sidebar.workspaces'> +/** Test-owned shell role: declares and renders the browsing region and the frame-wide overlay list. */ +type FrameProps = PropsRenderSlots<'sidebar.workspaces' | 'shell.overlay'> function SidebarFrame({ renderSlot }: FrameProps) { - return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })} + return ( + <> + {renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })} + {renderSlot('shell.overlay', {})} + + ) +} + +/** Declare the sidebar hole and the overlay list the rename dialog and the row notices mount in. */ +async function declareFrame(runtime: SlotTestRuntime): Promise { + await runtime.root.declare( + { + 'sidebar.workspaces': { kind: 'single', scope: 'root' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, + } as never, + SidebarFrame as never, + ) +} + +/** One Workspace holding the fixture Session. */ +async function seedWorkspace(runtime: SlotTestRuntime): Promise { + await runtime.workspaces.update((draft) => { + draft.items = [{ + workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha', + sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never + }) } describe('session rename through the assembled browser', () => { + it('renders independently registered Session actions in declared order', async () => { + const runtime = await createRuntime() + const selected = vi.fn() + await runtime.sessions.add({ + id: SID, + summary: { title: 'Persisted title', displayTitle: 'Session title', cwd: '/w/alpha' }, + session: { rename: vi.fn() }, + }) + await runtime.sessions.retainFor(runtime.ctx, SID, { source: 'mainView' }).ready + await seedWorkspace(runtime) + await declareFrame(runtime) + await runtime.mount({ inject: [...inject], apply }) + const registerAction = (id: string, order: number, priority: number, label: string) => { + runtime.slots.register( + { name: 'sidebar.workspaces.session.menu.item', id, order, priority }, + ({ sessionId, displayTitle, useMenuOpenState }: PropsRuntime<'sidebar.workspaces.session.menu.item'>) => { + const [, setMenuOpen] = useMenuOpenState() + return ( + { + setMenuOpen(false) + selected(id, sessionId, displayTitle) + }}> + {label} + + ) + }, + ) + } + // `order` places plugin rows after the shipped rows (100/200/300/400) + // even when the later registration has the lower shadowing priority + // assigned to dynamic browser packages; the first plugin row opens the + // plugin group with a hairline. + registerAction('export', 500, -1, 'Export action') + registerAction('last', 600, -2, 'Last action') + const view = runtime.renderRoot() + + const row = (await view.findByText('Session title')).closest('[role="treeitem"]')! + const trigger = within(row as HTMLElement).getByLabelText('会话“Session title”的操作') + fireEvent.click(trigger) + expect(view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ + '置顶会话', '重命名', '分叉会话', '归档会话', 'Export action', 'Last action', + ]) + expect(view.getAllByRole('separator')).toHaveLength(1) + const last = view.getByRole('menuitem', { name: 'Last action' }) + const exportRow = view.getByRole('menuitem', { name: 'Export action' }) + trigger.focus() + fireEvent.keyDown(trigger, { key: 'End' }) + expect(document.activeElement).toBe(last) + fireEvent.keyDown(last, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(exportRow) + fireEvent.click(exportRow) + expect(selected).toHaveBeenCalledWith('export', SID, 'Session title') + // The plugin row dismissed the menu through the bound open-state hook; + // the list returns focus to the trigger. + expect(view.queryByRole('menu')).toBeNull() + await act(async () => { await Promise.resolve() }) + expect(document.activeElement).toBe(trigger) + await runtime.dispose() + }) + it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => { const runtime = await createRuntime() const rename = vi.fn(async title => ({ @@ -63,16 +152,8 @@ describe('session rename through the assembled browser', () => { session: { rename }, }) await runtime.sessions.retainFor(runtime.ctx, SID, { source: 'mainView' }).ready - await runtime.workspaces.update((draft) => { - draft.items = [{ - workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha', - sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - }] as never - }) - await runtime.root.declare( - { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never, - SidebarFrame as never, - ) + await seedWorkspace(runtime) + await declareFrame(runtime) await runtime.mount({ inject: [...inject], apply }) const view = runtime.renderRoot() @@ -80,6 +161,8 @@ describe('session rename through the assembled browser', () => { const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')! fireEvent.click(within(row as HTMLElement).getByLabelText('会话“旧标题”的操作')) fireEvent.click(view.getByRole('menuitem', { name: '重命名', hidden: true })) + // The rename row dismissed the menu; the dialog lives in the overlay list. + expect(view.queryByRole('menu')).toBeNull() // The dialog seeds from the current title; submit a padded value. const input = await view.findByLabelText('会话名称') as HTMLInputElement @@ -111,16 +194,8 @@ describe('session rename through the assembled browser', () => { session: { rename }, }) await runtime.sessions.retainFor(runtime.ctx, SID, { source: 'mainView' }).ready - await runtime.workspaces.update((draft) => { - draft.items = [{ - workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha', - sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - }] as never - }) - await runtime.root.declare( - { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never, - SidebarFrame as never, - ) + await seedWorkspace(runtime) + await declareFrame(runtime) await runtime.mount({ inject: [...inject], apply }) const view = runtime.renderRoot() await runtime.flush() diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 680a9dcd74..f41199824f 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -1,13 +1,19 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' +import type { ComponentProps } from 'react' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { MenuOpenState, SessionRowOwnerProps } from '../src/client/contract/slots.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { RowDragProps } from '../src/client/rows/Rows.tsx' -import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' +import { + ProjectRowItem, SearchResultItem, SessionNodeItem as SessionNodeItemComponent, +} from '../src/client/rows/Rows.tsx' import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts' import { en, zh } from '../src/client/locales.ts' @@ -19,6 +25,20 @@ const tEn = makeTranslate(en, commonEn) as never const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId +/** The two row lists a Session row renders. */ +type RowSlotName = 'sidebar.workspaces.session.menu.item' | 'sidebar.workspaces.session.row.action' +type RowRenderSlot = PropsRenderSlots['renderSlot'] +type SessionNodeItemProps = ComponentProps + +const renderNoRowEntries: RowRenderSlot = () => null + +// Direct row specs use the real required Slot share, empty unless the case supplies entries. +function SessionNodeItem({ renderSlot = renderNoRowEntries, onRenameRequest = () => {}, ...props }: Omit< + SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' +> & Partial>) { + return +} + /** Half detection reads the row rect; jsdom rects are all-zero by default. */ function stubRect(row: HTMLElement): void { row.getBoundingClientRect = () => ({ @@ -64,13 +84,12 @@ describe('workspace browser rows', () => { id: sid('flat'), title: 'Flat Session', blank: false, running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } - const view = render() + const view = render() const title = screen.getByText('Flat Session') expect(title.previousElementSibling).toBeNull() view.rerender() + onOpen={vi.fn()} flat t={t} />) expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy() }) @@ -160,8 +179,7 @@ describe('workspace browser rows', () => { } const onOpen = vi.fn() render( - , + , ) const row = screen.getByRole('treeitem') @@ -172,6 +190,24 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) + it('keeps a row.action entry click in the strip, so a plain button does not open the row', () => { + const node: SessionNode = { + id: sid('session'), title: 'Session', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + const onOpen = vi.fn() + const pluginAction = vi.fn() + const renderSlot: RowRenderSlot = (name: RowSlotName) => (name === 'sidebar.workspaces.session.row.action' + ? + : null) + render() + fireEvent.click(screen.getByRole('button', { name: 'plugin' })) + expect(pluginAction).toHaveBeenCalledOnce() + expect(onOpen).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('treeitem')) + expect(onOpen).toHaveBeenCalledWith(node.id) + }) + it('reveals a clipped session title by scrolling it while the row is hovered', () => { const node: SessionNode = { id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, @@ -179,8 +215,7 @@ describe('workspace browser rows', () => { pinned: false, archived: false, } render( - , + , ) const row = screen.getByRole('treeitem') const title = screen.getByText(node.title) @@ -221,8 +256,7 @@ describe('workspace browser rows', () => { pinned: false, archived: false, } render( - , + , ) const row = screen.getByRole('treeitem') const title = screen.getByText(node.title) @@ -243,8 +277,7 @@ describe('workspace browser rows', () => { runningSubagentCount: 0, completed: false, hasActiveSchedule: true, updatedAt: 0, pinned: false, archived: false, } const view = render( - , + , ) const assertIndicator = (): HTMLElement => { @@ -262,8 +295,7 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) view.rerender( - , + , ) assertIndicator() }) @@ -275,8 +307,7 @@ describe('workspace browser rows', () => { id: sid('s1'), title: 'One', blank: false, running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, ...over, }} - currentId={undefined} now={0} onOpen={vi.fn()} - onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} + currentId={undefined} now={0} onOpen={vi.fn()} t={t} />, ) const stateDot = (view: ReturnType) => @@ -307,8 +338,7 @@ describe('workspace browser rows', () => { id: sid('owner'), title: 'Delegating', blank: false, running: false, runningSubagentCount: 2, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } - render() + render() const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull() expect(screen.getByText('2 个子代理运行中')).toBeTruthy() @@ -329,8 +359,7 @@ describe('workspace browser rows', () => { id: sid('owner'), title: 'Delegating', blank: false, running: true, runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } - render() + render() const row = screen.getByRole('treeitem') expect(row.querySelectorAll('[data-state="ongoing"]')).toHaveLength(1) expect(screen.getByText('进行中')).toBeTruthy() @@ -350,8 +379,7 @@ describe('workspace browser rows', () => { id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question', running: false, runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } - render() + render() const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="warning"]')).not.toBeNull() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() @@ -497,10 +525,13 @@ describe('workspace browser rows', () => { id: sid('s-blank'), title: 'ignored', blank: true, running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } - render() - // The placeholder has no content yet: no row verbs, no "now" stamp. + const rendered = vi.fn() + const renderSlot: RowRenderSlot = (name: RowSlotName) => { rendered(name); return null } + render() + // The placeholder has no content yet: no row verbs (neither list is + // asked for anything), no "now" stamp. expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull() + expect(rendered).not.toHaveBeenCalled() expect(screen.queryByText('刚刚')).toBeNull() // The hover card keeps title + status but drops the timestamp line. const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement @@ -515,41 +546,103 @@ describe('workspace browser rows', () => { } }) - it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => { + it('session row menu opens without opening the session and Escape closes it', () => { const onOpen = vi.fn() - const onRename = vi.fn() - const onFork = vi.fn() - const onArchive = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', blank: false, running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, } - render() - fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) - expect(onOpen).not.toHaveBeenCalled() - // Archive is not destructive (log and accounting slot remain): no danger styling. - expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/) - // Rename dispatches with the current display title (dialog prefill). - fireEvent.click(screen.getByRole('menuitem', { name: '重命名' })) - expect(screen.queryByRole('menu')).toBeNull() - expect(onRename).toHaveBeenCalledWith(node.id, 'One') - expect(onOpen).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) - fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' })) - expect(onFork).toHaveBeenCalledWith(node.id) - // Archive dispatches without opening the session. - fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) - fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) - expect(onArchive).toHaveBeenCalledWith(node.id) - expect(onRename).toHaveBeenCalledOnce() + render() + const trigger = screen.getByRole('button', { name: '会话“One”的操作' }) + fireEvent.click(trigger) + expect(screen.getByRole('menu')).toBeTruthy() expect(onOpen).not.toHaveBeenCalled() // Escape closes without selecting (Menu onClose path). - fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() + // The trigger toggles: a second click closes the menu it opened. + fireEvent.click(trigger) + fireEvent.click(trigger) + expect(screen.queryByRole('menu')).toBeNull() + expect(onOpen).not.toHaveBeenCalled() }) + it('renders the menu list with the row identity and open state, and returns focus to the trigger after an entry closes it', async () => { + const onOpen = vi.fn() + const onEntry = vi.fn() + const rendered = vi.fn() + // A stub satisfies the generic render signature only with erased parameter + // types; it narrows the owner and the occurrence options itself. + const renderSlot: RowRenderSlot = (name: RowSlotName, owner: object, opts?: object) => { + const { sessionId, displayTitle } = owner as SessionRowOwnerProps + const hookContext = opts !== undefined && 'hookContext' in opts ? opts.hookContext : undefined + rendered(name, owner, hookContext) + if (name !== 'sidebar.workspaces.session.menu.item') return null + // The stub stands in for the renderer, which binds this occurrence's + // hookContext into the entries' useMenuOpenState hook. + const [open, setMenuOpen] = hookContext as MenuOpenState + return ( + { + setMenuOpen(false) + onEntry(sessionId, displayTitle, open) + }}> + Export + + ) + } + const node: SessionNode = { + id: sid('s1'), title: 'One', blank: false, running: false, + runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, pinned: false, archived: false, + } + render() + + // Both lists receive the row identity; only the menu list carries the open state. + expect(rendered).toHaveBeenCalledWith( + 'sidebar.workspaces.session.menu.item', { sessionId: node.id, displayTitle: 'One' }, [false, expect.any(Function)], + ) + expect(rendered).toHaveBeenCalledWith( + 'sidebar.workspaces.session.row.action', { sessionId: node.id, displayTitle: 'One' }, undefined, + ) + expect(screen.queryByRole('menuitem')).toBeNull() + const trigger = screen.getByRole('button', { name: '会话“One”的操作' }) + trigger.focus() + fireEvent.click(trigger) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Export']) + expect(rendered).toHaveBeenCalledWith( + 'sidebar.workspaces.session.menu.item', { sessionId: node.id, displayTitle: 'One' }, [true, expect.any(Function)], + ) + fireEvent.click(screen.getByRole('menuitem', { name: 'Export' })) + expect(onEntry).toHaveBeenCalledWith(node.id, 'One', true) + // The entry dismissed the menu through the state pair; the list returns focus. + expect(screen.queryByRole('menu')).toBeNull() + await act(async () => { await Promise.resolve() }) + expect(document.activeElement).toBe(trigger) + expect(onOpen).not.toHaveBeenCalled() + }) + + it('renders the row action list inside the actions area after the menu trigger', () => { + const onOpen = vi.fn() + const onQuick = vi.fn() + const renderSlot: RowRenderSlot = (name: RowSlotName) => name === 'sidebar.workspaces.session.row.action' + ? ( +