diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index ad8ffb6167..04fa599e6f 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.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-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415 -2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61 +2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1 +2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 273b51ac7c..e21793e0ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -131,7 +131,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr ### Parent catalog prerequisites -V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). +Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; new complete entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs and retain unknown-mode membership without inventing discovery fields, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. Unreadable or unsupported headers are excluded from discovery; failures reading a recognized child preserve its header identity as unknown-mode membership. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The collector reports child decoding and descriptor failures as warnings with the child path; cancellation and source-consistency checks still stop preparation. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 69132ec176..637d8fcf02 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -131,7 +131,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o ### 父目录前置事实 -V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 +本段的运行时子会话错误处理部分由[逐会话目录准备](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代:打开父会话仍通过读取直属子会话补齐目录,但不可读子会话不再阻断健康父历史。V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;新增完整记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志与未知模式成员关系,不编造发现字段,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。不可读或不支持的 header 不参与发现;读取已识别子会话失败时,其 header 身份保留为未知模式成员关系。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。收集器将子解码和 descriptor 失败报告为带子路径的警告;取消与来源一致性检查仍中止准备。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml new file mode 100644 index 0000000000..e2f0f66dda --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.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-session-local-subagent-migration.md +2026-09-19-session-local-subagent-migration.md: 26911ea6e2cb31126d13e4c8c6488ff3b45563e6 +2026-09-19-session-local-subagent-migration.zh.md: 673040a3dc73d3665e42013a4704ef2d5911b23d diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md new file mode 100644 index 0000000000..26911ea6e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md @@ -0,0 +1,33 @@ +# Agent Note: Complete parent catalogs without migrating child catalogs + +Status: implemented + +English | [中文](2026-09-19-session-local-subagent-migration.zh.md) + +## Problem + +Opening a historical parent requires discovery facts from its direct children. A child's corrupt body or invalid descriptor, or an unreadable header elsewhere in the root, could reject the parent's V4 preparation and hide otherwise readable history. + +## Decision + +Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery retains its parent-catalog source. Unknown-mode rows remain browsable; `list_agents` continues to select only known continuable children. + +JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and retains the child's header identity. If the parent lacks a complete entry, migration appends `subagent/catalog`, projected as `mode: 'unknown'` alongside healthy siblings. Existing complete entries remain authoritative. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical. + +This partially supersedes child-failure propagation in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md) and [incomplete child evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md). Their format conversion, descriptor cardinality, conflict validation, and immutable publication rules remain active. + +## Alternatives considered + +**Skip catalog completion and discover children in each consumer.** This introduces incomplete tool results and additional UI states despite the parent's readable child descriptors. The parent catalog already serves these consumers. + +**Drop unreadable children from the catalog.** Published parents would lose their navigation entries. Header identity is sufficient to retain membership without inventing a mode or label. + +**Recursively prepare child catalogs.** A only needs B's own descriptor. Preparing B's descendants adds unrelated work and failure dependencies. + +**Refuse A when one child cannot be read.** The child's discovery metadata does not justify withholding the parent's healthy history. Warnings retain the child location without inventing mode or label fields. + +## Consequences + +Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. Catalog payload v0 retains its two known modes; v1 also supports `unknown`. Readers support both versions; healthy creation and complete historical facts retain v0, while unknown migration facts use v1. The V4 Session header is unchanged, and older readers reject the new payload version. + +Raw and compressed tests cover catalog completion, child-local errors, source changes and repairs, unchanged child generations, and deferred child-catalog preparation. The shipped Web migration snapshot checks the parent catalog with a corrupt sibling, then opens the child independently. Its additional historical child raises the corpus budget from ten to eleven roles; the guard rejects a twelfth role. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md new file mode 100644 index 0000000000..673040a3dc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md @@ -0,0 +1,33 @@ +# Agent Note:补齐父目录而不迁移子目录 + +Status: implemented + +[English](2026-09-19-session-local-subagent-migration.md) | 中文 + +## 问题 + +打开历史父会话需要直属子会话的发现事实。子日志正文损坏、descriptor 无效,或根目录其他位置的 header 不可读,都可能拒绝父会话的 V4 准备,使原本可读的历史无法打开。 + +## 决策 + +打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现继续读取父 catalog。模式未知的条目仍可浏览;`list_agents` 继续只选择已知可继续的子会话。 + +JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并保留该子的 header 身份。父目录没有完整条目时,迁移追加 `subagent/catalog`,与健康兄弟项一同投影为目录条目,其模式为 `mode: 'unknown'`。已有完整条目仍具有权威性。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。 + +本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)和[不完整子证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)中的子错误传播规则。其格式转换、descriptor 数量、冲突校验和不可变发布规则仍有效。 + +## 考虑过的替代方案 + +**跳过目录补齐,让每个消费者发现子项。** 父会话明明有可读的子 descriptor,却会因此产生不完整工具结果和额外 UI 状态。父 catalog 已经服务这些消费者。 + +**从目录删除不可读子会话。** 已发布父会话会丢失相应导航入口。Header 身份足以保留成员关系,无须编造模式或标签。 + +**递归准备子目录。** A 只需要 B 自身的 descriptor。准备 B 的后代会增加无关工作与失败依赖。 + +**一个子会话不可读就拒绝 A。** 子会话的发现元数据不足以成为阻断健康父历史的理由。警告保留子位置,不虚构 mode 或 label 字段。 + +## 后果 + +首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。Catalog 载荷 v0 保留两种已知模式,v1 另支持 `unknown`。读取器支持两版;正常创建及完整历史事实继续使用 v0,未知迁移事实使用 v1。V4 Session header 不变,旧读取器拒绝新载荷版本。 + +原始和压缩测试覆盖目录补齐、子会话错误隔离、来源变化与修复、子代际不变和延后的子目录准备。随产品 Web 迁移快照在损坏兄弟存在时检查父目录,再独立打开子会话。额外的历史子角色使语料预算由十个增加到十一个;守卫拒绝第十二个角色。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml index 4ede5bd457..bf6d6c2fd0 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md -2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92 -2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038 +2026-09-19-v3-incomplete-child-catalog-evidence.md: 400a84f376a7babc0a95492ca33a3bcd0761cd79 +2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 535951630bc600647724d50107e74e83ab626739 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md index 22ca915024..400a84f376 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md @@ -10,9 +10,9 @@ A valid V3 child log can have no own descriptor, an unsupported descriptor versi ## Decision -The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). +The V3→V4 migration appends a complete missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions retain unknown-mode membership through [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md). Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). -Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures. +The JSONL failure-propagation rules below are partially superseded by [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md); the converter still validates supplied facts and conflicts. Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. JSONL isolates invalid descriptor fields and corrupt child bodies while retaining known header identity; unreadable or unsupported headers are excluded from discovery. Changed source revisions still refuse publication. ## Alternatives considered @@ -24,6 +24,6 @@ Known child creation times must still match existing parent entries. Mode/label ## Consequences -A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. +A historical child with a readable header but no usable descriptor retains unknown-mode membership in its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later mode backfill is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md index c11e6de2c2..535951630b 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。 +V3→V4 迁移仅在恰好一个受支持的自身子 descriptor 提供发现字段时,追加完整的缺失父目录项。其他 descriptor 数量和不支持的版本通过[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)保留模式未知的成员关系。已有父目录项和子事件保持不变。这取代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中对缺失证据的拒绝。 -已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。 +JSONL 错误传播规则由[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)部分取代;转换器仍校验已提供事实及冲突。已知子创建时间必须匹配已有父目录项。只有恰好一个受支持的自身 descriptor 时才检查 mode/label 和 descriptor 字段。JSONL 隔离无效 descriptor 字段和损坏子正文,并保留已知 header 身份;不可读或不支持的 header 不参与发现。来源修订变化仍拒绝发布。 ## Alternatives considered @@ -24,6 +24,6 @@ V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字 ## Consequences -没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。 +历史子会话具有可读 header 但没有可用 descriptor 时,在父直属子目录中保留未知模式成员关系。已有目录项仍可见。V4 后继发布后,打开它不重新扫描历史子日志;自动补齐后续模式不属于本迁移。准备过程仍在发布前重新检查子修订,因此新出现的证据不能静默绕过来源一致性检查。 单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。 diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml index 94e92c9bac..969ab3fd9e 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-persistence-type-history.md -2026-09-11-persistence-type-history.md: c7cddea5ecc479a64ad5f24f5447e869779b7f9e -2026-09-11-persistence-type-history.zh.md: 001ce197c4cd12dc64a5e2bd710e39cb2b61f23a +2026-09-11-persistence-type-history.md: 87e87041dd44c4f671409a75c1b374312736342a +2026-09-11-persistence-type-history.zh.md: cfe5c4f7bd1998d4329dc422607cfe33273d1a5a diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md index c7cddea5ec..87e87041dd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md @@ -14,7 +14,7 @@ One normalized type model supplies the readable persistence catalog, the complet The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services. -Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. +Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [event-payload version rule](../../../../docs/persistence-changes/README.md#compatibility-rules) admits higher numeric payload versions when every old alternative remains unchanged and records the older-reader limitation. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once. diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md index 001ce197c4..cfe5c4f7bd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md @@ -14,7 +14,7 @@ Status: implemented 专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。 -自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 +自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules) 允许在原样保留所有旧分支时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。 diff --git a/AGENTS.md b/AGENTS.md index 7a128b6167..aa83ae8c08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) +pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build +make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options ``` ### Host sandbox failures diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..4b95df6766 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +# Short names for the Web and Desktop application commands. The package.json +# scripts they call stay the source of truth; docs/development.md documents both. +.DEFAULT_GOAL := help +.PHONY: help build web desktop dev-web dev-desktop + +PNPM ?= pnpm +ARGS ?= + +help: + @echo "make build pnpm run build complete repository build" + @echo "make web pnpm run start:web serve the built Web artifacts from source" + @echo "make desktop pnpm run start:desktop launch the built Desktop artifacts" + @echo "make dev-web pnpm run dev:web build, serve, and rebuild Web on source edits" + @echo "make dev-desktop pnpm run dev:desktop build, then launch Desktop" + @echo "ARGS='--no-open --port 3081' forwards options to the launched application;" + @echo "the Web commands accept dsh web flags, the Desktop launcher accepts none." + +build: + $(PNPM) run build + +web: + $(PNPM) run start:web $(ARGS) + +desktop: + $(PNPM) run start:desktop $(ARGS) + +dev-web: + $(PNPM) run dev:web $(ARGS) + +dev-desktop: + $(PNPM) run dev:desktop $(ARGS) diff --git a/README.i18n.yaml b/README.i18n.yaml index 904c7928d2..ce6f75b18d 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: 36adfe913902e75ce0673adc2cb5cb692151a291 -README.zh.md: f6861b4c9490741b5e5f2d825a370766178dd9f6 +README.md: 423bccd7d9352dbc48f1705f37d35642e74f5b41 +README.zh.md: c760f87e4ac3b7dbb7bb55d733e31db5ab379836 diff --git a/README.md b/README.md index 36adfe9139..423bccd7d9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Start with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md). +`pnpm run dev:web` builds, serves, and rebuilds client bundles on source edits in one terminal, and `make help` lists the matching Make targets for Web and Desktop; the guide's application commands section owns the full table. + For agents, follow [AGENTS.md](AGENTS.md). ## Citation diff --git a/README.zh.md b/README.zh.md index f6861b4c94..c760f87e4a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -75,6 +75,8 @@ pnpm dsh web 请先阅读[开发指南](docs/development.zh.md)与[架构文档](docs/architecture.zh.md)。 +`pnpm run dev:web` 会在一个终端里完成构建、启动,并在源码修改时重建 client bundle;`make help` 列出 Web 与 Desktop 对应的 Make target。完整表格见开发指南的「应用命令」一节。 + 面向 agent:请遵循 [AGENTS.md](AGENTS.md)。 ## 引用 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0cff0e5cdb..cb0eddf183 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: cf0e1400f2dc8cd29cc59cefbdda379df2e0b899 -README.zh.md: a01818f59ccf54f3f826c68505bfb830a9316664 +README.md: 33465e8c96df9cbfbecc038616caf297146ec971 +README.zh.md: d25d27851ca789a3db358157368b97c3669ed79d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index cf0e1400f2..33465e8c96 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -85,7 +85,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web profile -`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles. +`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index a01818f59c..d25d27851c 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -87,7 +87,7 @@ dsh --profile tui ## Web Profile -`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 +`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。 ```sh dsh web diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts index 0e58af42f3..ecf19a7253 100644 --- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts @@ -8,7 +8,7 @@ import { withDefaultWeb, webGet } from './default-web-process.ts' const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team' -it('boots the default Web profile without experimental Host modules, mounted plugins, or Client entries', async (test) => { +it('boots default Web without experimental modules or an active built-in Browser', async (test) => { await withDefaultWeb(test, async ({ url, request }) => { const auth = await webGet(url, test.signal) const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0] @@ -29,6 +29,11 @@ it('boots the default Web profile without experimental Host modules, mounted plu expect(roster.plugins.length).toBeGreaterThan(roster.entries.length) expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true) expect(roster.client.entries.length).toBeGreaterThan(0) + const browserName = '@deepseek-ai/dsh-client-ui-sidebar-browser' + const browserEntry = roster.entries.find(entry => entry.name === browserName) + expect(browserEntry).toBeDefined() + expect(browserEntry!.state).toBeUndefined() + expect(delivered.entries.some(entry => entry.id === browserName)).toBe(false) expect(experimentalRuntimeReferences(roster)).toEqual([]) const contaminated = await request('mount-experimental') diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 473b7d2968..ad60f636ee 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: d70f8895c54623812b175ec2f6b316915fe29514 -README.zh.md: 78f132baa394020376ad43a1c8ac54482a65c476 +README.md: aa2d9163067ea48b398e200b644fbeda6ea370a9 +README.zh.md: a27b551b4fc2ea37d5a2da45edbcf4b2901e5dde diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d70f8895c5..aa2d916306 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -93,7 +93,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. +The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. ## Package diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 78f132baa3..a27b551b4f 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -93,7 +93,7 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 +Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 ## 打包 diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 21e4f78bae..2cf02b02eb 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -318,7 +318,7 @@ describe('web e2e: clickable links gallery', () => { await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME }) browser = await chromium.launch() page = await newEnglishPage(browser) - await page.route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({ + await page.context().route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({ contentType: 'text/html', body: `

${new URL(route.request().url()).pathname}

`, })) @@ -454,5 +454,31 @@ describe('web e2e: clickable links gallery', () => { await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL) await markdown.locator(`a[href="${HTTP_URL}"]`).click() await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL) + + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Built-in browser', exact: true }).click() + await page.getByRole('menuitem', { name: 'New browser tab', exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'new-tab' }) + await page.keyboard.press('Escape') + const popupPromise = page.waitForEvent('popup') + await guideLink.click() + const popup = await popupPromise + try { + await popup.waitForURL(GUIDE_URL) + expect(await popup.evaluate(() => window.opener === null)).toBe(true) + expect(await browserAddress.inputValue()).toBe(HTTP_URL) + } finally { + await popup.close() + } + + await page.reload() + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'New browser tab', exact: true }).click() + await page.getByRole('menuitem', { name: 'Built-in browser', exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'sidebar' }) + await page.keyboard.press('Escape') + await guideLink.click() + await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL) + expect(tripwire.pageErrors).toEqual([]) }, 90_000) }) diff --git a/apps/web/tests/clickable-links-gallery.overlay.yml b/apps/web/tests/clickable-links-gallery.overlay.yml index d8adc80e3b..ad0114d151 100644 --- a/apps/web/tests/clickable-links-gallery.overlay.yml +++ b/apps/web/tests/clickable-links-gallery.overlay.yml @@ -7,3 +7,5 @@ - id: settings-controller config: nativeOpen: true +- id: ui-sidebar-browser + disabled: false diff --git a/apps/web/tests/expected/plugin-manager/icons.expected.md b/apps/web/tests/expected/plugin-manager/icons.expected.md new file mode 100644 index 0000000000..fb9df5edb1 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/icons.expected.md @@ -0,0 +1,5 @@ +Third-party bundle card: image, 36×36, decoded +Disabled Agent Teams card: image, 36×36, decoded +Agent Teams detail: image, 36×36, decoded +Third-party bundle detail: image, 36×36, decoded +Independent search row: image, 30×30, decoded diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md new file mode 100644 index 0000000000..41d6462e11 --- /dev/null +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -0,0 +1,28 @@ +- dialog "Settings": + - navigation: + - text: Settings + - button "General" + - button "Models" + - button "Built-in plugins" + - button "Agent presets" + - button "Open configuration file" + - button "Close" + - text: Permission Choose the default permission mode for new sessions + - button "Workspace Write" + - text: Language + - button "English" + - text: Appearance + - button "Light" + - button "Dark" + - button "System" [pressed] + - text: Font size Only affects conversation content 14 + - button "Increase font size" + - button "Decrease font size" + - text: px Conversation display Controls process content in completed turns + - button "Compact" + - text: Performance & usage Choose how much performance and usage information to show + - button "Detailed" + - text: Developer tools Show tools and information for debugging and troubleshooting + - switch "Developer tools" + - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior + - button "Queue" diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg b/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg new file mode 100644 index 0000000000..ad85688706 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json index 3224dfa296..71aa6bbd5f 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json @@ -1,6 +1,7 @@ { "name": "@fixture/bundle", "version": "0.0.1", + "icon": "./icon.svg", "private": true, "description": "Registry description for the fixture bundle.", "type": "module", diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json index 52a0c0f420..79083aafff 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json @@ -1,4 +1,5 @@ { "name": "Search plugin", + "icon": "./icon.svg", "description": "Search package introduction." } diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 716176a176..87adea12cc 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -1,4 +1,4 @@ -/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */ +/** Built dsh web + the `pnpm run dev:web --no-serve` watchers → browser HMR, with no page reload. */ import { existsSync, globSync, statSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -108,12 +108,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a const failures: unknown[] = [] try { subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime) + // Watchers only: the built `dsh web` below is the server under test, and the + // built tree is this lane's precondition rather than something to rebuild. watcher = subprocessCtx.subprocess.spawn(spawnSpec( - ['pnpm', 'run', 'dev:web'], + ['pnpm', 'run', 'dev:web', '--skip-build', '--no-serve'], REPO_ROOT, { ...clientBuildEnvironment }, )) - await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') + await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web --skip-build --no-serve') host = subprocessCtx.subprocess.spawn(spawnSpec( [process.execPath, binPath, 'web', '--no-open', '--port', '0'], world, diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index 8fb8b5942c..fc7392c433 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -92,7 +92,9 @@ describe('web e2e: Markdown inline-code links', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({}) + scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) diff --git a/apps/web/tests/no-sidebar-browser.overlay.yml b/apps/web/tests/no-sidebar-browser.overlay.yml new file mode 100644 index 0000000000..2af93975a4 --- /dev/null +++ b/apps/web/tests/no-sidebar-browser.overlay.yml @@ -0,0 +1,3 @@ +# Exercise the settings surface without a built-in browser provider. +- id: ui-sidebar-browser + disabled: true diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 373b0ba459..8ce5950d4c 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -140,6 +140,50 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('decodes manifest icons for disabled bundles and independent plugin rows', async () => { + const panel = await openPluginsPanel() + const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}` + const teamIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/icon.svg', import.meta.url)))).toString('base64')}` + const images: string[] = [] + const checkImage = async (selector: string, source: string, label: string) => { + const image = panel.locator(`${selector} img`).first() + await image.waitFor() + expect(await image.getAttribute('src')).toBe(source) + await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + const size = await image.evaluate((node: HTMLImageElement) => ({ + width: node.width, height: node.height, naturalWidth: node.naturalWidth, + })) + expect(size.naturalWidth).toBeGreaterThan(0) + images.push(`${label}: image, ${size.width}×${size.height}, decoded`) + } + await checkImage('[data-plugin-package="@fixture/bundle"]', fixtureIcon, 'Third-party bundle card') + const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]') + expect(await team.getByRole('switch').getAttribute('aria-checked')).toBe('false') + await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]', teamIcon, 'Disabled Agent Teams card') + try { + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + expect(await team.locator('img').evaluate((node: HTMLImageElement) => node.naturalWidth)).toBe(36) + if (MODE === 'refresh') { + const path = fileURLToPath(new URL(`../../../.artifacts/plugin-icons-${process.pid}-${colorScheme}.png`, import.meta.url)) + await page.screenshot({ path }) + console.log(`Plugin icon screenshot: ${path}`) + } + } + } finally { + await page.emulateMedia({ colorScheme: null }) + } + await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() + await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail') + await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() + await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail') + await checkImage('[data-plugin-row="fixture-search"]', fixtureIcon, 'Independent search row') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE) + await panel.getByRole('button', { name: '返回插件列表' }).click() + expect(tripwire.pageErrors).toEqual([]) + }) + it('localizes independent exports and falls back per field without activating the plugins', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) const panel = await openPluginsPanel() @@ -331,7 +375,7 @@ describe('web e2e: plugin manager', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', + 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', ]) }) }) diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index d8fccb3271..37fdd06073 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller' import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot' import { launchWebScaffold, webSnapshotMode } from './scaffold.ts' +const childFixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.1.v2.jsonl', import.meta.url)) const fixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.v2.jsonl', import.meta.url)) describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration through the Web Host', () => { @@ -32,6 +33,33 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t await mkdir(dirname(predecessor), { recursive: true }) await writeFile(predecessor, source) + const childId = SessionId('preset-migration-child') + const brokenId = SessionId('preset-migration-broken-child') + const childFixture = (await readFile(childFixturePath, 'utf8')).trimEnd().split('\n') + .map((line): Record => JSON.parse(line) as Record) + const childRows: Record[] = childFixture.slice(1).map((row, seq) => ({ ...row, seq, time: seq + 3 })) + const childHeader = { ...childFixture[0], id: childId, parentSession: id, cwd: scaffold.workspaceCwd } + const childSource = Buffer.concat([childHeader, ...childRows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n')))) + const childPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, 2, 'zstd') + const brokenPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, 2, 'zstd') + await mkdir(dirname(childPath), { recursive: true }) + await mkdir(dirname(brokenPath), { recursive: true }) + await writeFile(childPath, childSource) + await writeFile(brokenPath, Buffer.concat([ + zstdCompressSync(Buffer.from(JSON.stringify({ ...childHeader, id: brokenId }) + '\n')), + zstdCompressSync(Buffer.from('{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, + data: { turn: 1, reason: { kind: 'completed' } } }) + '\n')), + ])) + const brokenSource = await readFile(brokenPath) + const corruptHeaderId = SessionId('preset-migration-corrupt-header') + const corruptHeaderPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, corruptHeaderId, 2, 'zstd') + const corruptHeaderSource = Buffer.from('invalid Zstandard header frame') + await mkdir(dirname(corruptHeaderPath), { recursive: true }) + await writeFile(corruptHeaderPath, corruptHeaderSource) + const listed = await scaffold.ctx.sessionController.list({}, new AbortController().signal) + expect(listed.items.map(item => item.sessionId)).toEqual(expect.arrayContaining([id, childId, brokenId])) + expect(listed.items.map(item => item.sessionId)).not.toContain(corruptHeaderId) + const reader = await scaffold.ctx.sessionPersistence.open(id, 'read') try { expect(reader.header.agentPreset).toBe('ptc') @@ -48,7 +76,35 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t expect(resolved.agent.session.snapshotEvents() .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) + expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([ + { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, + { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, + ]) + const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd') + await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) + const signal = new AbortController().signal + const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } as const + const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal) + expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type'])) + await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) + .rejects.toThrow() + await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) + .resolves.toMatchObject({ hasMore: false }) + const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write') + await childWriter.close() + const childPublished = await readFile(childSuccessor) + const childText = Buffer.concat(scanZstdFrames(childPublished).frames + .map(({ start, end }) => zstdDecompressSync(childPublished.subarray(start, end)))).toString('utf8') + expect(childText.trimEnd().split('\n').map((line): unknown => JSON.parse(line))).toEqual([ + { ...childHeader, version: SESSION_FORMAT_VERSION, agentPreset: 'ptc' }, ...childRows, + ]) + expect(await readFile(childPath)).toEqual(childSource) + expect(await readFile(brokenPath)).toEqual(brokenSource) + await expect(readFile(generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, SESSION_FORMAT_VERSION, 'zstd'))) + .rejects.toMatchObject({ code: 'ENOENT' }) + + await scaffold.ctx.sessions.flush(resolved.agent.session) const publishedBytes = await readFile(successor) const published = Buffer.concat(scanZstdFrames(publishedBytes).frames .map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8') @@ -58,12 +114,23 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t && (row['data'] as { agentPreset: string }).agentPreset === 'code' ? { ...row, data: { agentPreset: 'ptc' } } : row), + { type: 'subagent/catalog', seq: rows.length, time: rows.at(-1)?.['time'], data: { + version: 1, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown', + } }, + { type: 'subagent/catalog', seq: rows.length + 1, time: rows.at(-1)?.['time'], data: { + version: 0, childId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child', + } }, // Agent activation closes its restored prefix with a fresh seed marker. - { type: 'session/end-seed', seq: rows.length, time: 0, data: {} }, + { type: 'session/end-seed', seq: rows.length + 2, time: 0, data: {} }, + { type: 'permission/preset', seq: rows.length + 3, time: 0, data: { preset: 'workspace-write' } }, + { type: 'sandbox/mode', seq: rows.length + 4, time: 0, data: { mode: 'workspace-write' } }, + { type: 'approval/policy', seq: rows.length + 5, time: 0, data: { policy: 'ask' } }, ].map(row => JSON.stringify(row)).join('\n') + '\n' const context = { sessionIds: [id], cwd: scaffold.workspaceCwd } expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context)) expect(await readFile(predecessor)).toEqual(source) + await expect(scaffold.ctx.sessionPersistence.open(corruptHeaderId, 'read')).rejects.toThrow(/invalid frame magic/) + expect(await readFile(corruptHeaderPath)).toEqual(corruptHeaderSource) expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl.zstd')).sort()) .toEqual(['session.v2.jsonl.zstd', `session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`]) } finally { diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index d84614d33c..53ccc552d4 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -722,10 +722,31 @@ describe('web e2e: settings modal and General preferences', () => { } }, 90_000) + it('hides link-opening settings when the built-in browser is disabled', async () => { + const fresh = await launchWebScaffold({ + developerTools: false, + extraOverlayPath: fileURLToPath(new URL('./no-sidebar-browser.overlay.yml', import.meta.url)), + }) + onTestFinished(() => fresh.close()) + const withoutBrowser = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) + onTestFinished(() => withoutBrowser.close()) + const browserConsole = watchConsole(withoutBrowser) + await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) + await withoutBrowser.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' }) + await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor() + expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0) + const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE) + expect(browserConsole.pageErrors).toEqual([]) + expect(browserConsole.warnings).toEqual([]) + }) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'dialog-en.expected.md', + 'dialog-no-browser.expected.md', 'dialog.expected.md', 'plugin-instances.expected.md', 'plugins.expected.md', diff --git a/apps/web/tests/sidebar-browser.e2e.ts b/apps/web/tests/sidebar-browser.e2e.ts index 7a3334bb1b..ca26a11a4c 100644 --- a/apps/web/tests/sidebar-browser.e2e.ts +++ b/apps/web/tests/sidebar-browser.e2e.ts @@ -29,7 +29,11 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false }) + scaffold = await launchWebScaffold({ + replayFixture: FIXTURE, + compareReplaySession: false, + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) browser = await chromium.launch() page = await newEnglishPage(browser) await page.route('https://browser.test/**', async (route) => { diff --git a/apps/web/tests/sidebar-browser.overlay.yml b/apps/web/tests/sidebar-browser.overlay.yml new file mode 100644 index 0000000000..66012abfce --- /dev/null +++ b/apps/web/tests/sidebar-browser.overlay.yml @@ -0,0 +1,3 @@ +# Opt in to Browser for its integration scenarios. +- id: ui-sidebar-browser + disabled: false diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 49be02de85..90571db3f4 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -409,7 +409,8 @@ describe('web e2e: shipped right Sidebar', () => { } await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) - await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(3) + await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) + expect(await column.locator('[data-sidebar-right-guide-entry="browser"]').count()).toBe(0) await column.locator('[data-sidebar-right-guide-entry="files"]').click() // A manual guide is closable beside Files and suppresses another add diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ddf3683c59..9984bf38de 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,7 +10,7 @@ import { productWebBundleIsolation } from './product-isolation.ts' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. ' - + 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.' + + 'For client-plugin HMR, run `pnpm run dev:web`, which starts `dsh web` and the rebuild watchers together.' const DEFAULT_CLIENT_TITLE = 'DSH Local Build' /** Escape build-time text before placing it in the HTML title element. */ diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index fa62039df7..673807013b 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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/api-gateway.md -api-gateway.md: 2cf7b2f345991fd20639e1294662cc64fc02093d -api-gateway.zh.md: 5b80db08de49591b27751e187aa6278384a7444e +api-gateway.md: aa8339219a563901d5d132f4f401325ca30a3d53 +api-gateway.zh.md: 99b2d9866d38e520880e5d13115c2e6370fca474 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 2cf7b2f345..aa8339219a 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -138,14 +138,13 @@ SRC solves only dispatch for a Host process running from source. The Client does ## Development mode -Web development prepares current Host, Client, and Web artifacts with `pnpm run build`, then runs the source Host and the Client plugin watcher in separate terminals: +Web development runs one command, which builds current Host, Client, and Web artifacts, starts the source Host, and keeps Client bundles rebuilt: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dsh.client` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. +`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; the `dev:web` watchers rebuild the client-face type emit, Client plugin and library bundles, and the Web shell on source edits. They do not analyze Host decorators or generate Remote Client DTS; only the complete build that `dev:web` runs first (or `pnpm run build`) does. Changing only a Remote method's implementation body without changing its contract does not require regenerating the Typert files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution: diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 5b80db08de..99b2d9866d 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -138,14 +138,13 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho ## 开发模式 -Web 开发先使用 `pnpm run build` 准备当前 Host、Client 与 Web 产物,然后在两个终端中分别运行源码 Host 和 Client plugin watcher: +Web 开发只需一条命令:它先构建当前 Host、Client 与 Web 产物,再启动源码 Host,并持续重建 Client bundle: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dsh.client` 声明的 Client 插件并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 +`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 的 watcher 会在源码修改时重建 client 侧类型输出、Client 插件与库 bundle 以及 Web 壳层。它们不会分析 Host decorator,也不会生成 Remote Client DTS;只有 `dev:web` 首先执行的完整构建(或 `pnpm run build`)才会。 只修改 Remote 方法实现体而不改变约定时,无需重新生成 Typert 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格约定,再让 Client 编译并打包新的贡献: diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 3d9fba9385..66b457c8fa 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 0bbf5ef7f4c0444635178ba0ba85fbb9527d76ec -architecture.zh.md: 0d28dc349fd9c655529bcc43deabc47055e0d394 +architecture.md: 5e0bb09770e013349bcc1326bea1eab9cdc3e595 +architecture.zh.md: a146c93c14ef0f563babf98f7311f1e5686895cc diff --git a/docs/architecture.md b/docs/architecture.md index 0bbf5ef7f4..5e0bb09770 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#prof Supported Node applications launch through named `dsh` profiles. The shipped profiles are `web`, `headless`, `sdk`, `sdk-minimal`, and `acp`, selected with `dsh --profile ` or `dsh `. `plugin` names the management command; a profile with that name requires `--profile plugin`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree. -Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`. +Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, root demo, and the root `start:web` and `dev:web` scripts in an explicit class and rejects a Node application path that bypasses `dsh`. The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0d28dc349f..a146c93c14 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -44,7 +44,7 @@ dsh --profile web --dump-config 受支持的 Node 应用通过具名 `dsh` profile 启动。随附 profile 为 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp`,可通过 `dsh --profile ` 或 `dsh ` 选择。`plugin` 表示管理命令;同名 profile 必须用 `--profile plugin` 选择。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。 -Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 +Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码、根 demo 以及根脚本 `start:web` 与 `dev:web` 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index b2b0467564..8c94de4306 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.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/cookbook/adding-a-package.md -adding-a-package.md: 8c818bc371b4fcd646878ec440dd713f8c116083 -adding-a-package.zh.md: f6a09b411e246c50fcf3f44117c05433b12cbd4c +adding-a-package.md: 60b5d6e047da5f9cdfab3ab504104a0f29b208f7 +adding-a-package.zh.md: 44a613bcf002009c4c58a34c0f68cab85ef097f3 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 8c818bc371..60b5d6e047 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -143,7 +143,9 @@ Fields fall back independently before view-specific name formatting, using the e - Title: locale `meta.title` → `package.json.name` → complete Cordis plugin name. - Description: locale `meta.description` → `package.json.description` → no description. -Export `/locale/en.json` for locale lookup; expose `/package.json` when package-field fallback is needed. +Export `/locale/en.json` for locale lookup; expose `/package.json` for package-field fallback or an icon declaration. + +For an image on bundle cards, details, and component rows, set top-level `"icon": "./icon.svg"` in that exported manifest and include the image in `files`. The path is relative to the declaring manifest's directory, including for independently exported plugin manifests. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. Images need no separate export and must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text; missing or undecodable images use the panel's default artwork. Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index f6a09b411e..44a613bcf0 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -143,7 +143,9 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex - 标题:locale `meta.title` → `package.json.name` → 完整 Cordis 插件名。 - 描述:locale `meta.description` → `package.json.description` → 不显示描述。 -导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退时,再开放 `<包名>/package.json`。 +导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退或声明图标时,开放 `<包名>/package.json`。 + +要在组合包卡片、详情和组件行显示图片,在该导出清单顶层设置 `"icon": "./icon.svg"`,并将图片加入 `files`。路径相对于声明清单所在目录,独立导出的插件清单也遵循此规则。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。图片不需要单独导出,且必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本;图片缺失或无法解码时使用面板的默认插画。 已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8959b05a1d..fd6198a8ca 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: ce8f1e7746562e696c96e6359ec9fcfde596d6e2 -development.zh.md: 85e95946dfd7210201f0807880d5ed1a92859356 +development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304 +development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0 diff --git a/docs/development.md b/docs/development.md index ce8f1e7746..7e8d0ded70 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` still requires the artifact tree from a prior complete build, but it samples the current version and Git state once at startup and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. +`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs that complete build first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. @@ -156,6 +156,19 @@ The PTC mode demo runs the same headless profile with code presentation enabled: pnpm run demo:ptc -- "summarize this workspace" ``` +### Application commands + +Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build`; `dev:*` runs that build first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Arguments after a Web command reach `dsh web`, for example `pnpm run dev:web --no-open --port 3081`; `dev:web` also accepts `--skip-build` to reuse the existing artifact tree and `--no-serve` to run only the rebuild watchers beside a server started elsewhere. Both Web commands use the normal Harness home, while the Desktop commands use the isolated development home described in the [Desktop README](../apps/desktop/README.md). The root `Makefile` names the same commands as `make web`, `make dev-web`, `make desktop`, `make dev-desktop`, and `make build`; `ARGS='--no-open'` forwards options. + ### TODO markers Use one of three comment tags to flag known issues in the code, ordered by urgency: diff --git a/docs/development.zh.md b/docs/development.zh.md index 85e95946df..84d0d76d8c 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -87,7 +87,7 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 仍需要先执行完整构建来准备产物树,但会在启动时读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 +`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行这次完整构建(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 @@ -160,6 +160,19 @@ PTC mode 演示启用代码式工具展示,并运行同一个 headless profile pnpm run demo:ptc -- "summarize this workspace" ``` +### 应用命令 + +Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 的产物;`dev:*` 先执行该构建再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Web 命令后面的参数会传给 `dsh web`,例如 `pnpm run dev:web --no-open --port 3081`;`dev:web` 还接受 `--skip-build` 复用现有产物树,以及 `--no-serve` 只运行重建 watcher、配合别处启动的服务器。两个 Web 命令使用正常的 Harness home,而 Desktop 命令使用 [Desktop README](../apps/desktop/README.zh.md) 描述的隔离开发 home。根目录 `Makefile` 以 `make web`、`make dev-web`、`make desktop`、`make dev-desktop` 和 `make build` 命名同一套命令;`ARGS='--no-open'` 用于转发参数。 + ### TODO 标记 请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序: diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 251a673f01..5f847038ca 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: 4e62b69e07daf5809d6070bcc182b71305e70151 -persistence-catalog.zh.md: c001f06e98ac3b7e331044510639b73ec640eb85 +persistence-catalog.md: 11cea4328db6e69c1ee08994a6d36fe26e308a7c +persistence-catalog.zh.md: 6207dc17023e566f73cb1e0c7cba40533a4a96ff diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4e62b69e07..11cea4328d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | +| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -894,13 +894,13 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +Source: [`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -2787,6 +2787,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -4547,7 +4555,7 @@ One of: - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4555,14 +4563,17 @@ One of: ### `SubagentCatalogEvent` -SHA-256: `3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e` +SHA-256: `1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0` -Sources: [`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +Sources: [`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) One of: - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5612,6 +5623,20 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | required | `"continuable"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | required | `string` | +| `mode` | required | `"continuable"` | +| `version` | required | `1` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5626,6 +5651,34 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | required | `"one-shot"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"unknown"` | +| `version` | required | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"one-shot"` | +| `version` | required | `1` | + ### `{ code, name, reason? }` @@ -8221,17 +8274,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | required | `number` | | `type` | required | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` +SHA-256: `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e) | +| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c001f06e98..6207dc1702 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -59,7 +59,7 @@ | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | +| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -896,13 +896,13 @@ export type SessionEvent = { ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -2789,6 +2789,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -4549,7 +4557,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4557,14 +4565,17 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` ### `SubagentCatalogEvent` -SHA-256: `3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e` +SHA-256: `1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0` -来源:[`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) 以下类型之一: - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5614,6 +5625,20 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | 必需 | `"continuable"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 必需 | `string` | +| `mode` | 必需 | `"continuable"` | +| `version` | 必需 | `1` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5628,6 +5653,34 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | 必需 | `"one-shot"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"unknown"` | +| `version` | 必需 | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"one-shot"` | +| `version` | 必需 | `1` | + ### `{ code, name, reason? }` @@ -8223,17 +8276,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | 必需 | `number` | | `type` | 必需 | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` +SHA-256: `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e) | +| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml new file mode 100644 index 0000000000..60ad1d8f73 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-20-unknown-child-catalog.md +2026-09-20-unknown-child-catalog.md: ba729661d60856ff948854641a1520339ab30210 +2026-09-20-unknown-child-catalog.zh.md: 468b4c9a3d3afab56ec8a9b03359016c9c0307e0 diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md new file mode 100644 index 0000000000..ba729661d6 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +English | [中文](2026-09-20-unknown-child-catalog.zh.md) + +## Summary + +Retain unreadable historical children in subagent/catalog with unknown mode. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" + decision: same-version +``` + + +## Compatibility + +Catalog payload v0 remains unchanged. Payload v1 adds unknown mode, and current readers accept v0 and v1. Complete facts still use v0; migration emits v1 for unknown children. Existing logs need no rewrite, and the Session header remains V4. Older readers reject v1. The classifier allows higher event payload versions only when all old payload alternatives remain unchanged; same-version widening and removal of old readers stay breaking. + + +## Verification + +Migration, restoration, projection, and Web regressions retain unknown membership and child-local errors. Classifier tests accept higher payload versions with preserved predecessors and reject same-version additions, invalid versions, changed or removed old alternatives, and header/surface changes. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json new file mode 100644 index 0000000000..14c4b254ad --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json @@ -0,0 +1,250 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:subagent/catalog", + "kind": "event", + "event": "subagent/catalog", + "surface": false, + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 9, + 11, + 12 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md new file mode 100644 index 0000000000..468b4c9a3d --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +[English](2026-09-20-unknown-child-catalog.md) | 中文 + +## 概述 + +在 subagent/catalog 中使用 unknown 模式保留不可读的历史子会话。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" + decision: same-version +``` + + +## 兼容性 + +Catalog 载荷 v0 保持不变。载荷 v1 增加 unknown 模式,当前读取器支持 v0 和 v1。完整事实继续使用 v0;迁移为未知子会话写入 v1。已有日志无需改写,Session header 保持 V4。旧读取器拒绝 v1。分类器仅在所有旧载荷分支保持不变时允许增加更高的事件载荷版本;同版本扩展和移除旧版读取支持仍属于破坏性变更。 + + +## 验证 + +迁移、恢复、投影和 Web 回归覆盖未知成员保留与子会话局部报错。分类器测试接受保留前代的更高载荷版本,并拒绝同版本新增、无效版本、旧分支修改或删除,以及 header/model-surface 变更。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/README.i18n.yaml b/docs/persistence-changes/README.i18n.yaml index 830e77b05e..c2d80f8829 100644 --- a/docs/persistence-changes/README.i18n.yaml +++ b/docs/persistence-changes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/README.md -README.md: 3fd95e48548fdb9c1d04330c2925096d072850d1 -README.zh.md: f4ab3b3d22b1aa2f7f5923e84fd4effc339874db +README.md: 2f3d2dd50adee751d9dce9ab250649ae13e45521 +README.zh.md: d2cec933b2210cc5652a7aa6c725517e1baff948 diff --git a/docs/persistence-changes/README.md b/docs/persistence-changes/README.md index 3fd95e4854..2f3d2dd50a 100644 --- a/docs/persistence-changes/README.md +++ b/docs/persistence-changes/README.md @@ -53,15 +53,18 @@ Every detected structural change requires an acknowledgement. Record creation an | Add an optional event-body property, including its complete subtree | `same-version` | | Make a required event-body property optional | `same-version` | | Add an ordinary event type | `same-version` | +| Add a higher numeric `data.version` to an ordinary event while retaining every old payload alternative unchanged | `same-version` | | Add an explicitly qualified attribution kind to a user/developer source slot whose before and after schemas carry the same supported policy | `same-version` | | Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | `version-bump` | | Change the Session header or event envelope | `version-bump` | A finalized checkpoint protects the accepted baseline without replacing these compatibility rules. At V4, optional additions, ordinary events, and qualified attribution additions can receive new same-version records. Breaking differences require a higher writer version and an acknowledgement containing its own header increase. The accepted V4 record cannot be updated to reuse its original 3→4 transition. +An ordinary event may add payload alternatives with a required, nonnegative integer `data.version` greater than every existing payload version, provided all existing alternatives remain structurally unchanged. The reader must retain support for the old payloads; adding variants at an existing version, dropping old versions, and changing the Session header or envelope remain breaking. Older readers may reject the new payload version. The [catalog acknowledgement](2026-09-20-unknown-child-catalog.md) records one such transition. + The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated breaking changes remain strict. Multiple context-form alternatives with the same wire kind form one group. -A same-version explanation states why old records can omit the addition and why older readers can ignore it without changing replay. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). +A same-version explanation states how old records remain readable and how older readers handle new records. Optional additions explain why older readers can ignore them without changing replay; a new payload version records the older-reader rejection. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). When a reader rejects a JSON property by name, declare the property as optional `never` and mark it with argument-free `@persistenceReserved`. The extractor retains the forbidden field, so allowing a JSON value later requires an existing-field type change. Required or JSON-valued properties cannot carry this marker. Unmarked optional `never` and `undefined` properties retain their existing omission behavior. diff --git a/docs/persistence-changes/README.zh.md b/docs/persistence-changes/README.zh.md index f4ab3b3d22..d2cec933b2 100644 --- a/docs/persistence-changes/README.zh.md +++ b/docs/persistence-changes/README.zh.md @@ -53,15 +53,18 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc | 添加可选事件体属性,包括其完整子树 | `same-version` | | 将必选事件体属性改为可选 | `same-version` | | 添加普通事件类型 | `same-version` | +| 为普通事件增加更高的数字 `data.version`,并原样保留所有旧载荷分支 | `same-version` | | 在前后 schema 均带有相同受支持策略的 user/developer 消息源字段中,添加显式声明为归属信息的 kind | `same-version` | | 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` | | 更改会话头或事件封装 | `version-bump` | 定稿检查点保护已接受基线,不替换这些兼容性规则。在 V4 中,可选新增、普通事件及符合条件的归属 kind 新增可以使用新的同版本记录。破坏性差异要求更高的写入器版本,以及包含自身头版本递增的确认记录。不能更新已接受 V4 记录以复用其原有 3→4 转换。 +普通事件可增加具有必选、非负整数 `data.version` 的载荷分支,新版本必须高于所有已有载荷版本,且所有已有分支结构保持不变。读取器必须保留旧载荷支持;在已有版本中增加分支、移除旧版本,以及修改 Session header 或事件封装仍属于破坏性变更。旧读取器可能拒绝新载荷版本。[Catalog 确认记录](2026-09-20-unknown-child-catalog.zh.md) 记录了此类演进。 + 提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关破坏性变更继续采用严格规则。同一 wire kind 的多个上下文形式分支归为一组。 -同版本说明须解释旧记录为何可以缺少新增内容,以及旧读取器为何可以忽略它而不改变回放。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 +同版本说明须解释旧记录如何保持可读,以及旧读取器如何处理新记录。可选新增须说明旧读取器为何可以忽略它而不改变回放;新增载荷版本须记录旧读取器的拒绝行为。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 当读取器按属性名拒绝 JSON 字段时,将该属性声明为可选 `never`,并添加无参数的 `@persistenceReserved` 标记。提取器保留这一禁止字段,因此后续允许 JSON 值属于已有字段类型变更。必选属性或允许 JSON 值的属性不能携带该标记。未标记的可选 `never` 和 `undefined` 属性保留现有省略行为。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 15481ef915..1f6bfd547a 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/historical-formats/README.md -README.md: 9bcfaa1246a03446d4d4fc95dd19f9ca60196301 -README.zh.md: 1f17367beb484feaeb289a42b3e5de50d3139be5 +README.md: a1064e815879d709fe466441bb4e8441c10b3aae +README.zh.md: 87c9d32fa6ca5248886dad663a926afc6983406f diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 9bcfaa1246..a1064e8158 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 583 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 1f17367beb..87c9d32fa6 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 583 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index db1c54ff38..59b8a86b4a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -14850,7 +14850,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871", + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", "schema": { "root": 0, "nodes": [ @@ -14864,7 +14864,7 @@ }, { "name": "ignorable", - "type": 9, + "type": 14, "optional": true }, { @@ -14879,7 +14879,7 @@ }, { "name": "type", - "type": 10, + "type": 15, "optional": false } ], @@ -14889,7 +14889,10 @@ "kind": "union", "types": [ 2, - 7 + 7, + 9, + 11, + 12 ] }, { @@ -14939,6 +14942,41 @@ "kind": "literal", "value": 0 }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, { "kind": "object", "properties": [ @@ -14959,7 +14997,7 @@ }, { "name": "mode", - "type": 8, + "type": 10, "optional": false }, { @@ -14974,6 +15012,72 @@ "kind": "literal", "value": "one-shot" }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, { "kind": "literal", "value": true @@ -21691,6 +21795,20 @@ "names": [], "sources": [] }, + { + "digest": "05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf", "schema": { @@ -30929,6 +31047,213 @@ "names": [], "sources": [] }, + { + "digest": "1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 6, + 8, + 10, + 11 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [ + "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" + ], + "sources": [ + "packages/subagent/subagent/src/catalog.ts:28" + ] + }, { "digest": "1df34a6c086866ffdc2dd9e8e5c581c3751a010cdf6b8538a7327f7a3237f0b4", "schema": { @@ -35234,15 +35559,49 @@ ] }, { - "digest": "3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e", + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", "schema": { "root": 0, "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, { "kind": "union", "types": [ - 1, - 6 + 2, + 7, + 9, + 11, + 12 ] }, { @@ -35250,28 +35609,28 @@ "properties": [ { "name": "childCreatedAt", - "type": 2, + "type": 3, "optional": false }, { "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", "type": 4, "optional": false }, { - "name": "version", + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", "type": 5, "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false } ], "indices": [] @@ -35297,27 +35656,62 @@ "properties": [ { "name": "childCreatedAt", - "type": 2, + "type": 3, "optional": false }, { "name": "childId", - "type": 3, + "type": 4, "optional": false }, { "name": "label", - "type": 3, - "optional": true + "type": 4, + "optional": false }, { "name": "mode", - "type": 7, + "type": 5, "optional": false }, { "name": "version", - "type": 5, + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, "optional": false } ], @@ -35326,15 +35720,85 @@ { "kind": "literal", "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" } ] }, - "names": [ - "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" - ], - "sources": [ - "packages/subagent/subagent/src/catalog.ts:24" - ] + "names": [], + "sources": [] }, { "digest": "3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408", @@ -68069,6 +68533,63 @@ "names": [], "sources": [] }, + { + "digest": "a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "a0f906856fc06f1c6f2083eb970e5a657a82b1a84c06c095445e5d6d161cda80", "schema": { @@ -70165,144 +70686,6 @@ "names": [], "sources": [] }, - { - "digest": "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 9, - "optional": true - }, - { - "name": "seq", - "type": 3, - "optional": false - }, - { - "name": "time", - "type": 3, - "optional": false - }, - { - "name": "type", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 7 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 5, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 8, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "subagent/catalog" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "ae63d9d1080ed8945031594d93f8d96651d7101b848719cc3e1475875cd3d649", "schema": { @@ -71795,6 +72178,63 @@ "names": [], "sources": [] }, + { + "digest": "b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0", "schema": { @@ -81359,6 +81799,63 @@ "packages/schedule/schedule/src/types.ts:105" ] }, + { + "digest": "e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "e2d0367e5716bc188435e51718ebff8ce873190625fa93b1c5331102d98758fa", "schema": { diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index a1164e5891..30b50d6a0a 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.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/sidebar-right.md -sidebar-right.md: 9ad6bd63ed63c5c59dbe29cd1229a6412ad22de2 -sidebar-right.zh.md: d0a967f400ea80ab696e24e1b4f46d7d8664eff8 +sidebar-right.md: 27e5219aec8997cf0d168ac9ea2665baaf943906 +sidebar-right.zh.md: 63f2c1081009778e191789038ffd1923312fc55b diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 9ad6bd63ed..27e5219aec 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -136,7 +136,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name - **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list` and watched by expanded directory, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). -- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). +- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Chat's [link preference](../../packages/client/ui-chat/README.md) controls whether Assistant Markdown opens HTTP(S) links here. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). - **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)). diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index d0a967f400..63f2c10810 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -136,7 +136,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,按展开目录建立监听,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 -- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 +- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Chat 的[链接偏好](../../packages/client/ui-chat/README.zh.md)决定 Assistant Markdown 是否在此打开 HTTP(S) 链接。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 - **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 034d835426..805d66f003 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subagent.md -subagent.md: d642fe1658926b17cb29f280803f12e61228be60 -subagent.zh.md: ff153c7861b4ac46bb058efd00a2f5a50181b6f2 +subagent.md: 67f9bc31b46617ea2f7b1ec81bf8c52fd62720cb +subagent.zh.md: a69e032ed96a24a980badaf9e14d924311e1d175 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index d642fe1658..67f9bc31b4 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -8,7 +8,7 @@ Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subag Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) -The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. +The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. Historical children with unavailable descriptors have `mode: 'unknown'`; their header identity remains discoverable without granting continuation capabilities. ## Two kinds of capability, discovered two ways @@ -465,7 +465,7 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct - **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap. - **Fork seeding** uses [`CreateAgentOptions.seed`](core.md#creation-and-ownership) (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/runtime-diagnostics/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). -`SubagentCatalogEntry` describes one successful direct-child creation fact; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics. +`SubagentCatalogEntry` describes a direct child with complete or unknown-mode discovery information; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics. diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index ff153c7861..a69e032ed9 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -8,7 +8,7 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) -`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。 +`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。历史子会话的 descriptor 不可用时使用 `mode: 'unknown'`,保留 header 身份供发现,但不授予继续执行能力。 ## 两类能力,两种发现方式 @@ -469,7 +469,7 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将 - **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 - **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 -`SubagentCatalogEntry` 描述一条成功创建直接子级的事实;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。 +`SubagentCatalogEntry` 描述一条完整或未知模式的直接子级目录记录;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。 diff --git a/package.json b/package.json index 4d36479376..eb4b2b92c9 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ "demo:ptc": "node scripts/demo-ptc.mjs", "demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", + "start:web": "node --import tsx/esm apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 32ffc5e1cd..e63c0b2a46 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: 56d0f17b815a4616707d33bfd84b77446125f742 -README.zh.md: 9d754a5a61da548e1fd6dd10d1332582bdd3d126 +README.md: 49f38e5af8f0939dd8f1ccafcd7928b10954ca33 +README.zh.md: 57112fc5ce88567ecfe415ed7ee4dd5ac9d34224 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 56d0f17b81..49f38e5af8 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -63,7 +63,7 @@ Attachment authorization reads declared content fields of built-in Session event `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. 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. +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. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity. ## Session media references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 9d754a5a61..57112fc5ce 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -63,7 +63,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 `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。普通 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)。 +引用保活本地会话数据、作用域 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)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。 ## 会话媒体引用 diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 08d935233c..ec880192f0 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -805,6 +805,7 @@ export class Session implements SessionFace { } private buildSnapshot(): SessionSnapshot { + const identity = this.projections.values().subagent return { sessionId: this.sessionId, pendingSubmissions: this.pendingSubmissions, @@ -812,7 +813,9 @@ export class Session implements SessionFace { subagent: this.address === undefined ? null : { - address: this.address, + address: this.address.mode === 'unknown' && identity != null + ? { ...this.address, mode: identity.mode } + : this.address, ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), }, removed: this.removed, diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index a22badc1f1..069ee3bf32 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -364,7 +364,7 @@ function validateAddress( reason: 'unsupported', }) } - if (identity.mode !== address.mode) { + if (address.mode !== 'unknown' && identity.mode !== address.mode) { throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', { childSessionId: address.childSessionId, }) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 37294a6dbb..728df9e55b 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -407,7 +407,7 @@ export type SessionAddress = readonly kind: 'subagent' readonly parentSessionId: SessionId readonly childSessionId: SessionId - readonly mode: 'one-shot' | 'continuable' + readonly mode: 'one-shot' | 'continuable' | 'unknown' } /** One non-activating Session projection read. */ diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index aed5009fdd..55d182110a 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -817,6 +817,25 @@ describe('subagent catalogs', () => { }) }) + it('resolves an unknown browsing mode from the child projection without changing parent membership', async ({ mock, start }) => { + mock.load(sessionWorld) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + const entries = [{ id: S2, createdAt: 1, mode: 'unknown' as const }] + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries }) + const address = manager.subagentAddress(S2)! + expect(address).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unknown' }) + manager.resolveTarget(address) + const child = manager.get(S2) + expect(child.getSnapshot().subagent?.address.mode).toBe('unknown') + manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'subagent', seq: 0, + value: { seq: SessionSeq(0), mode: 'continuable', label: 'repaired child' } }) + await child.open() + expect(child.getSnapshot().subagent?.address).toEqual({ ...address, mode: 'continuable' }) + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual(entries) + await manager.dispose() + }) + it('publishes pushed membership without a catalog request or an instantiated parent', ({ mock, remote }) => { const manager = makeManager(mock, remote) manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true })) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 04b43972e9..341aaf4e86 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -451,6 +451,16 @@ describe('SessionHistoryController', () => { }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).resolves.toMatchObject({ + records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], + }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) await expect(transport.page({ address: { kind: 'subagent', diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index aed223888a..98b11724ed 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 8439fb9ec1173ba24f9775ac476e64d23534a594 -README.zh.md: 0bb8c9819dacabed01905f6b7aff8c770ea3caae +README.md: 6f24c46485abe3761994ef7aacbea90840e01bfb +README.zh.md: b3a8aaa2746848bcc347291dad27f015e8a004e3 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 8439fb9ec1..6f24c46485 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -68,7 +68,7 @@ Before you boot, you can print the exact configuration the app will mount: the d ### Reading plugin display metadata -Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. +Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. The reader also loads `package.json.icon` as an image data URL, even when locale text is complete; an icon error preserves valid text alongside the diagnostic. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. ### Startup and reload failures diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 0bb8c9819d..b3a8aaa274 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -68,7 +68,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head ### 读取插件展示元信息 -使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 +使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。即使 locale 文本完整,读取器也会将 `package.json.icon` 加载为图片 data URL;图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 ### 启动与重载失败 diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts index 434b233d1e..131082ce8a 100644 --- a/packages/boot/app-boot/src/package-meta.ts +++ b/packages/boot/app-boot/src/package-meta.ts @@ -1,7 +1,7 @@ -/** Read plugin display metadata through exported locale resources without evaluating plugin code. */ +/** Read plugin display text and icons through exported resources without evaluating plugin code. */ -import { readdirSync, readFileSync } from 'node:fs' -import { dirname } from 'node:path' +import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { dirname, extname, isAbsolute, relative, resolve, sep, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader' import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' @@ -11,6 +11,35 @@ const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u type DisplayFields = Record<'title' | 'description', string | undefined> +/** Maximum raw icon bytes admitted to plugin metadata. */ +const MAX_ICON_BYTES = 256 * 1024 +const ICON_MEDIA_TYPES = new Map([ + ['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], +]) + +function iconOf(value: unknown, manifestPath: string): string | undefined { + const icon = textOf(value, `${manifestPath}: icon`) + if (icon === undefined) return undefined + if (isAbsolute(icon) || win32.isAbsolute(icon) || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(icon)) { + throw new Error(`${manifestPath}: icon must be a relative file path`) + } + const mediaType = ICON_MEDIA_TYPES.get(extname(icon).toLowerCase()) + if (mediaType === undefined) throw new Error(`${manifestPath}: icon must be SVG, PNG, JPEG, or WebP`) + const directory = realpathSync(dirname(manifestPath)) + const file = realpathSync(resolve(directory, icon)) + const local = relative(directory, file) + if (local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) { + throw new Error(`${manifestPath}: icon must remain inside its manifest directory`) + } + const stat = statSync(file) + if (!stat.isFile()) throw new Error(`${manifestPath}: icon must be a regular file`) + if (stat.size > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`) + const bytes = readFileSync(file) + if (bytes.length > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`) + return `data:${mediaType};base64,${bytes.toString('base64')}` +} + /** * Resolve a plugin resource through the active Node ESM resolver without evaluating it. * @param specifier - complete resource module specifier, including its locale filename. @@ -104,7 +133,9 @@ function localizedText( } /** - * Read meta.title and meta.description from a plugin's exported locale JSON files. + * Read localized display text and the icon declared in a plugin's exported package.json. + * Icons are manifest-relative SVG, PNG, JPEG, or WebP files of at most 256 KiB, + * contained in the manifest directory after realpath resolution. Icon failures retain display text. * Non-package specifiers are skipped without invoking the resource resolver. * Language files share the directory containing the resolved English resource; * each file is resolved through the complete plugin specifier before reading. @@ -112,25 +143,29 @@ function localizedText( * maps retain an English fallback, ultimately the full module specifier for titles and empty for descriptions. * @param specifier - configured plugin module name, including any package subpath. * @param parentURL - owning Loader tree's module-resolution base. - * @returns translated or fallback fields, a diagnostic, or undefined for non-package specifiers or absent display text. + * @returns display fields and any icon diagnostic, or undefined for non-package specifiers or absent metadata. */ export function readPluginMeta(specifier: string, parentURL: string): PluginLocalizedMeta | undefined { if (barePackageName(specifier) === undefined) return undefined try { const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL) const dictionaries = englishPath === undefined ? new Map() : dictionariesOf(englishPath, specifier, parentURL) - const english = dictionaries.get('en') - const manifestPath = english?.title === undefined || english.description === undefined - ? optionalResourcePath(`${specifier}/package.json`, parentURL) - : undefined + const manifestPath = optionalResourcePath(`${specifier}/package.json`, parentURL) const manifest = manifestPath === undefined ? undefined : readObject(manifestPath) const title = localizedText('title', dictionaries, fallbackText(manifest?.name), specifier) const description = localizedText('description', dictionaries, fallbackText(manifest?.description), '') - if (title === undefined && description === undefined) return undefined - return { + const text = { ...title === undefined ? {} : { title }, ...description === undefined ? {} : { description }, } + let icon: string | undefined + try { + icon = manifestPath === undefined ? undefined : iconOf(manifest?.icon, manifestPath) + } catch (error) { + return { ...text, error: `Plugin metadata for ${specifier}: ${String(error)}` } + } + if (title === undefined && description === undefined && icon === undefined) return undefined + return { ...text, ...icon === undefined ? {} : { icon } } } catch (error) { return { error: `Plugin metadata for ${specifier}: ${String(error)}` } } diff --git a/packages/boot/app-boot/tests/package-meta.spec.ts b/packages/boot/app-boot/tests/package-meta.spec.ts index 07b9af374b..73484f7db9 100644 --- a/packages/boot/app-boot/tests/package-meta.spec.ts +++ b/packages/boot/app-boot/tests/package-meta.spec.ts @@ -1,6 +1,6 @@ /** Plugin locale resources resolve independently of entry execution and package display fields. */ -import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -161,15 +161,122 @@ describe('plugin locale display metadata', () => { expect(readPluginMeta('localized', parentURL)?.error).toContain('meta.title must be a non-empty string') }) - it('does not inspect package fallback when both English fields are supplied', () => { - manifest({ './locale/*.json': './locale/*.json', './package.json': './fallback.json' }) - file(join(dir, 'fallback.json'), '{') + it('reads the manifest icon even when both English fields are supplied', () => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon: './art/team.svg' }) + file(join(dir, 'art', 'team.svg'), '') dictionary('en', { meta: { title: 'Plugin', description: 'Locale introduction' } }) expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'Plugin' }, description: { en: 'Locale introduction' }, + icon: `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`, }) }) + it.each([ + ['svg', 'image/svg+xml'], ['png', 'image/png'], ['jpg', 'image/jpeg'], + ['jpeg', 'image/jpeg'], ['webp', 'image/webp'], ['SVG', 'image/svg+xml'], + ])('encodes a package-local %s icon without executing the plugin', (extension, mediaType) => { + manifest({ '.': './index.js', './package.json': './package.json' }, { icon: `./icon.${extension}` }) + const bytes = Buffer.from([0, 1, 127, 128, 255]) + writeFileSync(join(dir, `icon.${extension}`), bytes) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: 'localized', icon: `data:${mediaType};base64,${bytes.toString('base64')}`, + }) + }) + + it('reads an icon-only subexport relative to its own manifest without inheriting the package icon', () => { + manifest({ './package.json': './package.json', './search/package.json': './search/manifest.json' }, { icon: './root.png' }) + file(join(dir, 'root.png'), 'root') + file(join(dir, 'search', 'manifest.json'), JSON.stringify({ icon: './icon.webp' })) + file(join(dir, 'search', 'icon.webp'), 'search') + expect(readPluginMeta('localized/search', parentURL)).toEqual({ + icon: `data:image/webp;base64,${Buffer.from('search').toString('base64')}`, + }) + expect(readPluginMeta('localized/review', parentURL)).toBeUndefined() + }) + + it.each([null, false, 1, {}, [], '', ' '])('retains localized text while rejecting malformed icon %j', (icon) => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon }) + dictionary('en', { meta: { title: 'Plugin', description: 'Introduction' } }) + dictionary('zh', { meta: { title: '插件' } }) + const { error, ...meta } = readPluginMeta('localized', parentURL)! + expect(meta).toEqual({ title: { en: 'Plugin', zh: '插件' }, description: { en: 'Introduction' } }) + expect(error).toContain('icon must be a non-empty string') + }) + + it.each(['/tmp/icon.svg', 'C:\\icon.png', 'C:icon.png', '\\\\server\\share\\icon.png', 'https://example.test/icon.svg', 'data:image/svg+xml,'])('rejects non-relative icon %s', (icon) => { + manifest({ './package.json': './package.json' }, { icon, description: 'Introduction' }) + const { error, ...meta } = readPluginMeta('localized', parentURL)! + expect(meta).toEqual({ title: 'localized', description: 'Introduction' }) + expect(error).toContain('icon must be a relative file path') + }) + + it.each(['icon.gif', 'icon.html', 'icon', 'icon.svg?query'])('rejects unsupported icon file %s', (icon) => { + manifest({ './package.json': './package.json' }, { icon }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be SVG, PNG, JPEG, or WebP') + }) + + it('reports missing files, directories, and paths outside the manifest directory', () => { + manifest({ './package.json': './package.json' }, { icon: './missing.svg' }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('missing.svg') + mkdirSync(join(dir, 'missing.svg')) + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be a regular file') + manifest({ './package.json': './package.json' }, { icon: '../localized-other/icon.svg' }) + file(join(root, 'node_modules', 'localized-other', 'icon.svg'), 'outside') + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory') + }) + + it('accepts an icon at the byte limit and rejects one byte more', () => { + manifest({ './package.json': './package.json' }, { icon: './icon.png' }) + const bytes = Buffer.alloc(256 * 1024) + writeFileSync(join(dir, 'icon.png'), bytes) + expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/png;base64,${bytes.toString('base64')}`) + writeFileSync(join(dir, 'icon.png'), Buffer.alloc(bytes.length + 1)) + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB') + }) + + it('rejects an icon that grows beyond the limit after its size check', () => { + manifest({ './package.json': './package.json' }, { icon: './icon.png' }) + const icon = join(dir, 'icon.png') + file(icon, 'small') + const original = fs.statSync + const stat = vi.spyOn(fs, 'statSync') + try { + stat.mockImplementation(new Proxy(original, { + apply(target, receiver: unknown, args: unknown[]): unknown { + const result: unknown = Reflect.apply(target, receiver, args) + if (String(args[0]).endsWith(join('localized', 'icon.png'))) writeFileSync(icon, Buffer.alloc(256 * 1024 + 1)) + return result + }, + })) + syncBuiltinESMExports() + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB') + } finally { + stat.mockRestore() + syncBuiltinESMExports() + } + }) + + it('rejects icons reached through an external directory link and accepts internal links', () => { + manifest({ './package.json': './package.json' }, { icon: './art/icon.svg' }) + const outside = join(root, 'outside') + file(join(outside, 'icon.svg'), 'outside') + const link = join(dir, 'art') + symlinkSync(outside, link, 'junction') + try { + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory') + } finally { + unlinkSync(link) + } + const inside = join(dir, 'images') + file(join(inside, 'icon.svg'), 'inside') + symlinkSync(inside, link, 'junction') + try { + expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/svg+xml;base64,${Buffer.from('inside').toString('base64')}`) + } finally { + unlinkSync(link) + } + }) + it('keeps separate plugin exports independent even when their JavaScript entries share a directory', () => { manifest({ './search': './lib/search.js', './review': './lib/review.js', @@ -285,8 +392,9 @@ describe('plugin locale display metadata', () => { }) const specifier = 'localized/locale/en.json' expect(resolveSync.mock.calls).toEqual(version === 'v1' - ? [[specifier, parentURL, {}], [specifier, parentURL, {}]] - : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }]]) + ? [[specifier, parentURL, {}], [specifier, parentURL, {}], ['localized/package.json', parentURL, {}]] + : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }], + [parentURL, { specifier: 'localized/package.json', attributes: {} }]]) }) it('rejects case-equivalent language names in a directory listing', () => { diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 1f4d203152..72f81ef676 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -233,9 +233,10 @@ - id: ui-sidebar-documentpreview name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' - # Sandboxed HTTP(S) Browser tabs, including loopback targets. + # Web profiles opt in; Desktop retains sandboxed HTTP(S) Browser tabs. - id: ui-sidebar-browser name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'" - id: ui-sidebar-terminal name: '@deepseek-ai/dsh-client-ui-sidebar-terminal' diff --git a/packages/bundle/web-app/tests/browser-defaults.spec.ts b/packages/bundle/web-app/tests/browser-defaults.spec.ts new file mode 100644 index 0000000000..73ee9b1a82 --- /dev/null +++ b/packages/bundle/web-app/tests/browser-defaults.spec.ts @@ -0,0 +1,41 @@ +/** Profile defaults and explicit overrides for the shipped Browser entry. */ +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' +import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import * as Browser from '@deepseek-ai/dsh-client-ui-sidebar-browser' +import { expect, it, onTestFinished } from 'vitest' + +it.each([ + { profile: 'web', override: undefined, disabled: true }, + { profile: 'custom-web', override: undefined, disabled: true }, + { profile: undefined, override: undefined, disabled: true }, + { profile: 'desktop', override: undefined, disabled: false }, + { profile: 'web', override: false, disabled: false }, + { profile: 'desktop', override: true, disabled: true }, +])('applies Browser availability for $profile with override $override', async ({ profile, override, disabled }) => { + const rows = loadOverlayPatches('browser-defaults', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) + .flatMap(patch => patch.insert ?? []).filter(row => row.id === 'ui-sidebar-browser') + expect(rows).toHaveLength(1) + expect(rows[0]!.name).toBe('@deepseek-ai/dsh-client-ui-sidebar-browser') + const configured = applyEntryPatches(rows, override === undefined ? [] : [{ id: 'ui-sidebar-browser', disabled: override }], + (message) => { throw new Error(message) }) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + if (profile !== undefined) { + ctx.provide('profileContext', { + name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: '/profile/package.json', + cwd: '/workspace', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined, + }) + } + ctx.baseUrl = 'file:///' + await ctx.plugin(Loader).await() + // Keep the real companion in the source-plane loader without native package resolution. + ctx.loader.builtins.browser = Browser + await ctx.loader.root.update([{ ...configured[0]!, name: 'cordis:browser' }]) + await ctx.loader.await() + const entry = ctx.loader.resolve('ui-sidebar-browser') + expect(entry.disabled).toBe(disabled) + expect(entry.fiber !== undefined).toBe(!disabled) +}) diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index 07ad3d545d..f6861208ea 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/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/hmr/README.md -README.md: e459da10332ffdb3af422b75f639ce25df512487 -README.zh.md: 54e2f3af63472dedbfc75c5b4400b5d93e14f311 +README.md: 5dae1463ceed315c3409bf97de80649f10e83fa8 +README.zh.md: a77befcf63cd78be66b7e5b636d86367af6fdcdf diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index e459da1033..5dae1463ce 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -29,7 +29,7 @@ The shipped Web composition mounts this transport for live plugin changes. Durin ### Starting the reload chain -Run `pnpm run dev:web` (or a watch process using the shared Client tsdown preset) against the same host; rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning. +Run `pnpm run dev:web`, which starts the host and the rebuild watchers together (`--no-serve` attaches only the watchers to a host started elsewhere, as does any watch process using the shared Client tsdown preset); rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning. ### What a reload does diff --git a/packages/client/hmr/README.zh.md b/packages/client/hmr/README.zh.md index 54e2f3af63..a77befcf63 100644 --- a/packages/client/hmr/README.zh.md +++ b/packages/client/hmr/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 启动重载链路 -对同一个宿主运行 `pnpm run dev:web`(或使用共享 Client tsdown 预设的 watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。 +运行 `pnpm run dev:web`,它会同时启动宿主与重建 watcher(`--no-serve` 则只把 watcher 接到别处启动的宿主上,使用共享 Client tsdown 预设的任何 watch 进程亦然);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。 ### 一次重载做什么 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index c42928d650..7d9e60da65 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: b916802917bfdd406fb333174371507d466b8e19 -README.zh.md: 01de4d633cb1501a03200aa96f72e89622d0a7d8 +README.md: eea8eeda01c3d0bd30669cdf3743c64be0d76fbe +README.zh.md: 58fef8e8c47ae5f67c8ecbf614a36bbfbf2e20b3 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b916802917..eea8eeda01 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -32,7 +32,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. -HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. +Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: Built-in browser (default) opens a new right-Sidebar Browser tab, while New browser tab opens an external tab. The setting is shown only while the built-in browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. ## System prompt row diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 01de4d633c..58fef8e8c4 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 -Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +设置 → 通用设置 → 聊天链接打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「内置浏览器」(默认)打开新的右侧 Sidebar Browser tab,「浏览器新标签页」打开外部标签页。该设置项仅在内置浏览器可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 ## 系统提示词行 diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index dbf9014320..021a2b0616 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -26,16 +26,25 @@ export type PerformanceUsageMode = typeof PERFORMANCE_USAGE_MODES[number] /** Preserve detailed accounting for users without an explicit preference. */ export const DEFAULT_PERFORMANCE_USAGE: PerformanceUsageMode = 'detailed' +/** Destinations for ordinary clicks on Chat HTTP(S) links. */ +export type LinkOpening = 'sidebar' | 'new-tab' + +/** Preserve the built-in browser for users without an explicit preference. */ +export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar' + /** Durable Chat section shared by the Host schema and browser scope. */ export interface ChatSettings { /** Presentation mode for completed Turn process content. */ transcriptView: TranscriptViewMode /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode + /** Default destination for Chat HTTP(S) links. */ + linkOpening: LinkOpening } /** Durable Chat schema; also the wire envelope the browser scope validates against. */ export const ChatSettingsSchema: z = z.object({ + linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING), performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), }) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 21a63ab782..079a5e2512 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' -import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-browser/client' @@ -34,7 +34,8 @@ import { en, NS, zh } from './locale.ts' import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx' import { createChatStore } from './stores.ts' import { TranscriptViewPolicy } from './transcript-view.ts' -import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts' +import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, type ChatSettings } from '../chat-settings.ts' +import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpeningRow.tsx' import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx' import { PerformanceUsagePolicy } from './performance-usage.ts' import { useTurnDataValue } from './chat/use-turn-data.ts' @@ -82,6 +83,33 @@ export function apply(ctx: Context): void { const chatStore = createChatStore() const chatScrollPositions = new Map() const chatSettings = ctx.settingsScope.bind({ namespace: CHAT_SETTINGS_NAMESPACE }) + const linkOpening = createSnapshotStore(chatSettings.getSnapshot().value?.linkOpening ?? DEFAULT_LINK_OPENING) + ctx.effect(() => chatSettings.subscribe(() => { + const accepted = chatSettings.getSnapshot().value?.linkOpening + if (accepted !== undefined) linkOpening.set(accepted) + })) + ctx.inject(['sidebarRightTabs'], (scope) => { + const tabs = scope.sidebarRightTabs + const browserAvailable: ObservableSnapshot = { + getSnapshot: () => tabs.get('browser') !== undefined, + subscribe: listener => tabs.subscribe(listener), + } + scope.slots.inject('settings.general.item', () => scope.slots.register({ + name: 'settings.general.item', + id: 'link-opening', + order: 14, + locale: NS, + inject: (): LinkOpeningRowInjected => ({ + hooks: { linkOpening, browserAvailable }, + setLinkOpening: (destination) => { + linkOpening.set(destination) + void chatSettings.set('linkOpening', destination).catch((_error: unknown) => { + // The local choice remains usable when persistence is unavailable. + }) + }, + }), + }, LinkOpeningRow)) + }) const transcriptView = new TranscriptViewPolicy(chatSettings) const performancePolicy = new PerformanceUsagePolicy(chatSettings) const performanceUsage = performancePolicy.mode @@ -158,7 +186,7 @@ export function apply(ctx: Context): void { ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` }) }, openExternalLink: (url) => { - if (ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { + if (linkOpening.getSnapshot() === 'sidebar' && ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { ctx.sidebarRight.openTab('browser', { params: { url } }) } else { window.open(url, '_blank', 'noopener,noreferrer') diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index d5b007b7cf..5e811b7963 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -150,7 +150,7 @@ export interface ChatViewInjected { } /** Open the current source file of a skill referenced by a sent message. */ openSkill: (name: string) => void - /** Open one HTTP(S) message link in a Sidebar Browser tab. */ + /** Open one HTTP(S) message link at the selected destination, using an external tab if Sidebar Browser is unavailable. */ openExternalLink: (url: string) => void openFile: (path: string, options?: OpenFileOptions) => Promise loadOlder: () => void diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 344fef13d0..51d3adca00 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -31,6 +31,10 @@ export const zh = { 'settings.performance.description': '选择性能与用量信息展示的详细程度', 'settings.performance.compact': '简洁', 'settings.performance.detailed': '详细', + 'settings.links.title': '聊天链接打开方式', + 'settings.links.description': '选择聊天中 HTTP(S) 链接的打开位置', + 'settings.links.sidebar': '内置浏览器', + 'settings.links.newTab': '浏览器新标签页', 'settings.transcript.title': '对话显示', 'settings.transcript.description': '控制已完成轮次的过程内容', 'settings.transcript.normal': '标准', @@ -141,6 +145,10 @@ export const en = { 'settings.performance.description': 'Choose how much performance and usage information to show', 'settings.performance.compact': 'Compact', 'settings.performance.detailed': 'Detailed', + 'settings.links.title': 'Open chat links in', + 'settings.links.description': 'Choose where HTTP(S) links in chat open', + 'settings.links.sidebar': 'Built-in browser', + 'settings.links.newTab': 'New browser tab', 'settings.transcript.title': 'Conversation display', 'settings.transcript.description': 'Controls process content in completed turns', 'settings.transcript.normal': 'Normal', diff --git a/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx b/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx new file mode 100644 index 0000000000..b17a3e4929 --- /dev/null +++ b/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx @@ -0,0 +1,47 @@ +/** General Settings row for Chat HTTP(S) link destinations. */ +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { LinkOpening } from '../../chat-settings.ts' +import { PreferenceRow } from './PreferenceRow.tsx' + +/** Registration-side link-opening preference. */ +export interface LinkOpeningRowInjected { + hooks: { + /** Current destination, bound as useLinkOpening. */ + linkOpening: ObservableSnapshot + /** Whether the built-in browser is registered, bound as useBrowserAvailable. */ + browserAvailable: ObservableSnapshot + } + /** Change the default destination for Chat HTTP(S) links. */ + setLinkOpening: (destination: LinkOpening) => void +} + +/** Full Settings-row props. */ +export type LinkOpeningRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'chat'> + & InjectFace + +/** + * Render the link-opening destination selector. + * @param props - Composed Settings slot props. + * @returns The preference row. + */ +export function LinkOpeningRow({ useLinkOpening, useBrowserAvailable, setLinkOpening, t }: LinkOpeningRowProps) { + const destination = useLinkOpening(value => value) + const browserAvailable = useBrowserAvailable(value => value) + if (!browserAvailable) return null + return ( + { setLinkOpening(value as LinkOpening) }} + /> + ) +} diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 7977740640..dff3bbe376 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -19,6 +19,8 @@ import { import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createChatStore } from '../src/client/stores.ts' +import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' +import type { LinkOpeningRowInjected } from '../src/client/settings/LinkOpeningRow.tsx' usePinnedBrowserLanguages('zh-CN') @@ -47,9 +49,16 @@ function sessionFakeFor() { } satisfies SessionBehaviorOverrides } -async function bench() { +async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) + const chatSettings = stubSettingsScope() + if (initialSettings !== undefined) chatSettings.publish({ value: initialSettings }) + runtime.ctx.provide('settingsScope', { + developerTools: { enabled: createSnapshotStore(true) }, + bind: ({ namespace }: { namespace: string }) => namespace === CHAT_SETTINGS_NAMESPACE + ? chatSettings.scope + : stubSettingsScope().scope, + } as never) const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() } runtime.ctx.provide('layout', layout as never) const sidebarRight = { @@ -57,11 +66,13 @@ async function bench() { openTab: vi.fn<(kind: string, options?: unknown) => void>(), } runtime.ctx.provide('sidebarRight', sidebarRight as never) + const browserAvailable = createSnapshotStore(true) const sidebarRightTabs = { - get: vi.fn<(kind: string) => object | undefined>(() => ({})), + get: vi.fn<(kind: string) => object | undefined>(() => browserAvailable.getSnapshot() ? {} : undefined), + subscribe: (listener: () => void) => browserAvailable.subscribe(listener), register: vi.fn(() => () => {}), } - runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never) + if (withBrowserRegistry) runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never) runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openWorkspacePath = vi.fn( () => Promise.resolve({ ok: true, value: { opened: true } }), @@ -88,9 +99,10 @@ async function bench() { runtime.slots.installLocale(locale) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) - await runtime.mount({ inject: [...injectChat], apply: applyChat }) + const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat }) runtime.renderRoot() const chatViewApi = (reference: SessionReference) => { @@ -104,7 +116,13 @@ async function bench() { return { instance, injected } } return { - runtime, layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, rootReference, openSession, + get linkPreference() { + const row = runtime.slots.entries('settings.general.item').find(entry => entry.options.id === 'link-opening')! + const preference: object = row.inject!() + return preference as LinkOpeningRowInjected + }, + runtime, chat, chatSettings, browserAvailable, + layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, rootReference, openSession, } } @@ -184,6 +202,106 @@ describe('Chat inject API', () => { } }) + it('applies restored and live link destinations without remounting the Chat view', async () => { + const settings: ChatSettings = { transcriptView: 'compact', performanceUsage: 'detailed', linkOpening: 'new-tab' } + const b = await bench(settings) + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + const { injected } = b.chatViewApi(b.rootReference) + const source = b.linkPreference.hooks.linkOpening + expect(source.getSnapshot()).toBe('new-tab') + injected.openExternalLink('https://example.test/restored') + expect(open).toHaveBeenLastCalledWith('https://example.test/restored', '_blank', 'noopener,noreferrer') + expect(b.sidebarRight.openTab).not.toHaveBeenCalled() + + b.chatSettings.publish({ value: { ...settings, linkOpening: 'sidebar' } }) + expect(source.getSnapshot()).toBe('sidebar') + injected.openExternalLink('http://example.test/live') + expect(b.sidebarRight.openTab).toHaveBeenLastCalledWith('browser', { params: { url: 'http://example.test/live' } }) + + b.linkPreference.setLinkOpening('new-tab') + expect(b.chatSettings.set).toHaveBeenCalledWith('linkOpening', 'new-tab') + injected.openExternalLink('http://example.test/selected') + expect(open).toHaveBeenLastCalledWith('http://example.test/selected', '_blank', 'noopener,noreferrer') + + await b.chat.dispose() + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false) + b.chatSettings.publish({ value: { ...settings, linkOpening: 'sidebar' } }) + expect(source.getSnapshot()).toBe('new-tab') + } finally { + open.mockRestore() + await b.runtime.dispose() + } + }) + + it('follows browser registration and routes absent browsers externally', async () => { + const b = await bench() + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const available = b.linkPreference.hooks.browserAvailable + const changed = vi.fn() + const unsubscribe = available.subscribe(changed) + try { + const { injected } = b.chatViewApi(b.rootReference) + expect(available.getSnapshot()).toBe(true) + b.browserAvailable.set(false) + expect(available.getSnapshot()).toBe(false) + expect(changed).toHaveBeenCalledOnce() + injected.openExternalLink('https://example.test/disabled') + expect(open).toHaveBeenCalledWith('https://example.test/disabled', '_blank', 'noopener,noreferrer') + b.browserAvailable.set(true) + injected.openExternalLink('https://example.test/enabled') + expect(b.sidebarRight.openTab).toHaveBeenCalledWith('browser', { params: { url: 'https://example.test/enabled' } }) + expect(b.linkPreference.hooks.linkOpening.getSnapshot()).toBe('sidebar') + } finally { + unsubscribe() + open.mockRestore() + await b.runtime.dispose() + } + }) + + it('keeps Chat available while the optional tab registry appears and leaves', async () => { + const b = await bench(undefined, false) + try { + expect(b.runtime.slots.entries('conversation.view')).toHaveLength(1) + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false) + const registry = await b.runtime.mount({ + apply(ctx) { ctx.provide('sidebarRightTabs', b.sidebarRightTabs as never) }, + }) + await vi.waitFor(() => { + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(true) + }) + await registry.dispose() + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false) + expect(b.runtime.slots.entries('conversation.view')).toHaveLength(1) + } finally { + await b.runtime.dispose() + } + }) + + it('keeps local link choices usable when settings cannot persist', async () => { + const b = await bench() + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + const { injected } = b.chatViewApi(b.rootReference) + b.chatSettings.publish({ mode: 'memory', status: 'unavailable' }) + b.chatSettings.set.mockResolvedValueOnce(false) + b.linkPreference.setLinkOpening('new-tab') + b.chatSettings.publish({ value: undefined }) + injected.openExternalLink('https://example.test/local') + expect(open).toHaveBeenCalledWith('https://example.test/local', '_blank', 'noopener,noreferrer') + expect(b.sidebarRight.openTab).not.toHaveBeenCalled() + + b.chatSettings.set.mockRejectedValueOnce(new Error('settings unavailable')) + b.linkPreference.setLinkOpening('sidebar') + await Promise.resolve() + injected.openExternalLink('https://example.test/local-sidebar') + expect(b.sidebarRight.openTab).toHaveBeenCalledWith('browser', { params: { url: 'https://example.test/local-sidebar' } }) + } finally { + open.mockRestore() + await b.runtime.dispose() + } + }) + it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 028752d24c..e085bba34e 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -47,7 +47,11 @@ async function bench() { } as never) runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never) - runtime.ctx.provide('sidebarRightTabs', { register: vi.fn(() => () => {}) } as never) + runtime.ctx.provide('sidebarRightTabs', { + register: vi.fn(() => () => {}), + get: vi.fn(() => ({})), + subscribe: vi.fn(() => () => {}), + } as never) runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openSession = vi.fn<(id: SessionId) => void>() runtime.ctx.provide('uiWorkspace', { @@ -94,7 +98,7 @@ describe('Chat apply wiring', () => { expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id)) .toEqual(['stats']) expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id)) - .toEqual(['transcript-view', 'performance-usage', 'composer-enter']) + .toEqual(['transcript-view', 'performance-usage', 'link-opening', 'composer-enter']) await b.runtime.dispose() }) @@ -110,7 +114,7 @@ describe('Chat apply wiring', () => { expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal') b.chatSettings.publish({ - status: 'ready', value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true, + status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true, }) expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') await b.runtime.dispose() @@ -123,7 +127,7 @@ describe('Chat apply wiring', () => { expect(face.hooks.performanceUsage.getSnapshot()).toBe('detailed') face.setPerformanceUsage('compact') expect(b.chatSettings.set).toHaveBeenCalledWith('performanceUsage', 'compact') - b.chatSettings.publish({ value: { transcriptView: 'compact', performanceUsage: 'compact' } }) + b.chatSettings.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'compact' } }) expect(face.hooks.performanceUsage.getSnapshot()).toBe('compact') for (const entry of [ b.runtime.slots.entries('conversation.composer.dock').find(entry => entry.options.id === 'stats')!, diff --git a/packages/client/ui-chat/tests/chat-settings.client.spec.ts b/packages/client/ui-chat/tests/chat-settings.client.spec.ts index ad5ae6ab89..d2c146c40d 100644 --- a/packages/client/ui-chat/tests/chat-settings.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-settings.client.spec.ts @@ -21,14 +21,20 @@ describe('ui-chat Host settings', () => { await fiber.await() const ns = CHAT_SETTINGS_NAMESPACE - expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed' }) + expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed', linkOpening: 'sidebar' }) await ctx.settings.update(ns, { transcriptView: 'normal' }) - expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed' }) + expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' }) await expect(ctx.settings.update(ns, { transcriptView: 'dense' })).rejects.toThrow() await ctx.settings.update(ns, { performanceUsage: 'compact' }) expect(ctx.settings.get(ns)).toMatchObject({ performanceUsage: 'compact' }) await expect(ctx.settings.update(ns, { performanceUsage: 'hidden' })).rejects.toThrow() + await ctx.settings.update(ns, { linkOpening: 'new-tab' }) + expect(ctx.settings.get(ns)).toMatchObject({ linkOpening: 'new-tab' }) + await ctx.settings.update(ns, { linkOpening: 'sidebar' }) + expect(ctx.settings.get(ns)).toMatchObject({ linkOpening: 'sidebar' }) + await expect(ctx.settings.update(ns, { linkOpening: 'popup' })).rejects.toThrow() + await fiber.dispose() expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) }) diff --git a/packages/client/ui-chat/tests/performance-usage.client.spec.ts b/packages/client/ui-chat/tests/performance-usage.client.spec.ts index 00e862cfe6..54ddfdd1de 100644 --- a/packages/client/ui-chat/tests/performance-usage.client.spec.ts +++ b/packages/client/ui-chat/tests/performance-usage.client.spec.ts @@ -21,10 +21,10 @@ describe('PerformanceUsagePolicy', () => { it('adopts accepted Host settings at construction and after updates', () => { const host = stubSettingsScope() - host.publish({ value: { transcriptView: 'normal', performanceUsage: 'compact' } }) + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'compact' } }) const policy = new PerformanceUsagePolicy(host.scope) expect(policy.mode.getSnapshot()).toBe('compact') - host.publish({ value: { transcriptView: 'normal', performanceUsage: 'detailed' } }) + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' } }) expect(policy.mode.getSnapshot()).toBe('detailed') }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts index 84717bb5c0..ec8595496a 100644 --- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -30,18 +30,18 @@ describe('TranscriptViewPolicy', () => { const host = stubSettingsScope() const policy = new TranscriptViewPolicy(host.scope) - host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) expect(policy.mode.getSnapshot()).toBe('normal') policy.setMode('normal') expect(host.set).not.toHaveBeenCalled() - host.publish({ value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) expect(policy.mode.getSnapshot()).toBe('compact') }) it('adopts an accepted section standing at construction', () => { const host = stubSettingsScope() - host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal') }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index ca5aeb1e00..82c928ac97 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -9,7 +9,8 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx' -import type { PerformanceUsageMode } from '../src/chat-settings.ts' +import type { LinkOpening, PerformanceUsageMode } from '../src/chat-settings.ts' +import { LinkOpeningRow } from '../src/client/settings/LinkOpeningRow.tsx' import { en, zh } from '../src/client/locale.ts' afterEach(cleanup) @@ -80,6 +81,50 @@ describe('TranscriptViewRow', () => { }) +describe('LinkOpeningRow', () => { + it('follows browser availability without clearing the saved destination', () => { + const b = mount() + const browserAvailable = createSnapshotStore(false) + const source = createSnapshotStore('new-tab') + render() + expect(screen.queryByText('Open chat links in')).toBeNull() + act(() => { browserAvailable.set(true) }) + expect(screen.getByRole('button', { name: 'New browser tab' })).toBeDefined() + act(() => { browserAvailable.set(false) }) + expect(screen.queryByText('Open chat links in')).toBeNull() + expect(source.getSnapshot()).toBe('new-tab') + }) + + it.each([ + [en, 'Open chat links in', 'Built-in browser', 'New browser tab'], + [zh, '聊天链接打开方式', '内置浏览器', '浏览器新标签页'], + ] as const)('selects either destination using localized labels (%s)', (dictionary, title, sidebar, newTab) => { + const b = mount('compact', dictionary) + const source = createSnapshotStore('sidebar') + const setLinkOpening = vi.fn((destination: LinkOpening) => { source.set(destination) }) + render() + expect(screen.getByText(title)).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: sidebar })) + fireEvent.click(screen.getByRole('menuitem', { name: newTab })) + expect(setLinkOpening).toHaveBeenLastCalledWith('new-tab') + fireEvent.click(screen.getByRole('button', { name: newTab })) + fireEvent.click(screen.getByRole('menuitem', { name: sidebar })) + expect(setLinkOpening).toHaveBeenLastCalledWith('sidebar') + expect(screen.getByRole('button', { name: sidebar }).getAttribute('aria-expanded')).toBe('false') + expect(b.setTranscriptView).not.toHaveBeenCalled() + }) +}) + describe('PerformanceUsageRow', () => { it('selects compact statistics independently of conversation display', () => { const b = mount() diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 74be8bbf76..24fc6a38eb 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md -README.md: b6072cd7d75446a0a45abe4009273235e4ff4e1b -README.zh.md: 44b5ce85ccd6250f30904f80b01ee083f804f1ce +README.md: 96ecd3777ef0ce4ce6babd977eef055ad5c683ec +README.zh.md: fb6eb850b868913bdccec175500ff4e26d7a2157 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index b6072cd7d7..96ecd3777e 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. -Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. +Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 44b5ce85cc..fb6eb850b8 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。 -计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts index e3dff30e29..d44b8f2dd9 100644 --- a/packages/client/ui-plan/src/client/plan.ts +++ b/packages/client/ui-plan/src/client/plan.ts @@ -74,7 +74,7 @@ export function parsePlanAddress(address: string): PlanAddress | undefined { if (parts.length === 2) return { session: { kind: 'session', sessionId: parts[0] as SessionId }, callId: parts[1] as ToolCallId, } - if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable')) return { + if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable' || parts[3] === 'unknown')) return { session: { kind: 'subagent', parentSessionId: parts[1] as SessionId, childSessionId: parts[2] as SessionId, mode: parts[3] }, callId: parts[4] as ToolCallId, } diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts index a27b03c185..57be9acd36 100644 --- a/packages/client/ui-plan/tests/plan-resource.client.spec.ts +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -36,7 +36,7 @@ describe('plan history resource', () => { expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) expect(b.closed).toHaveBeenCalledOnce() }) - it.each(['one-shot', 'continuable'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { + it.each(['one-shot', 'continuable', 'unknown'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) const address = `dsh-resource://plan/subagent/parent/child/${mode}/call` const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index dfbe7fcedb..df24d45717 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/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-plugin-manager/README.md -README.md: d7bcd2299d93dab90c8440565ac87112b37c9c10 -README.zh.md: 368058dabc27968b86088c34ee387dc5dd5bd364 +README.md: c04a614668d5afb2e09c1f923ac9be473a4aab5a +README.zh.md: f0b30c91cb87ce96349d92086673809139956e0a diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index d7bcd2299d..c04a614668 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Beta** where the feature is one — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. -Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Installation previews still use registry or manifest information. +Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. ### Installing a bundle diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 368058dabc..f0b30c91cb 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于 beta 功能的带 **Beta** 标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 -已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。安装预览仍使用注册表或 manifest 信息。 +已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 ### 安装一个组合包 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 96286b3b16..a5f1da6197 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -202,6 +202,10 @@ color: var(--dsw-alias-label-secondary); } +.packageImage { + object-fit: contain; +} + .cardMain { flex: 1; min-width: 0; @@ -1112,13 +1116,13 @@ label.registryOption { line-height: 22px; } -/* The package name under the title: the exact spec that installs the package - elsewhere, where a title of its own or a shortened name hides it. */ +/* The package or row identity under the title: the exact spec that installs the + package elsewhere, where a title of its own or a shortened name hides it. */ .detailName { margin: 0; font-size: 12px; line-height: 18px; - color: var(--dsw-alias-label-caption); + color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 5235d53cd0..5ef0212a3c 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -17,7 +17,7 @@ import { IconChevronRightOutlineRegular, IconCloseOutlineMedium, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, IconWarningOutlineRegular, Input, Modal, - PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal, + PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -130,21 +130,23 @@ const ITEM_ARTWORK = new Map ReactNode>([ ['web-search', PluginArtworkSearch], ]) -/** The artwork of the official bundles with artwork of their own, by package name. */ -const PACKAGE_ARTWORK = new Map ReactNode>([ - ['@deepseek-ai/dsh-experimental-agent-team-profile', PluginArtworkTeam], -]) - /** An official plugin's card and page artwork; plugins without their own get the default. */ function itemArtwork(id: string): ReactNode { const Artwork = ITEM_ARTWORK.get(id) ?? PluginArtworkDefault return } -/** A package's card and page artwork; packages without their own get the default. */ -function packageArtwork(name: string): ReactNode { - const Artwork = PACKAGE_ARTWORK.get(name) ?? PluginArtworkDefault - return +/** Manifest images remain isolated from the page DOM; a failed decode keeps the position's default artwork. */ +function PackageArtwork({ src, row = false, size = row ? ROW_ARTWORK_SIZE : CARD_ARTWORK_SIZE }: { + readonly src: string | undefined + readonly row?: boolean + readonly size?: number +}): ReactNode { + const [failedSource, setFailedSource] = useState() + const Fallback = row ? PluginArtworkSubagent : PluginArtworkDefault + return src === undefined || src === failedSource + ? + : { setFailedSource(src) }} /> } /** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */ @@ -234,7 +236,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { {...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }} >
- +
{configure?.has(row) === true ? ( @@ -367,7 +369,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnab title={title} t={t} onOpen={onOpen} - icon={packageArtwork(pkg.name)} + icon={} tags={( <> {beta ? {t('statusBeta')} : null} @@ -439,7 +441,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot }: { const key = rowConfigKey(pkg.name, row.rowId) return (
- } /> + } />

{rowTitle}

@@ -491,7 +493,7 @@ function PackageDetail({ crumbLabel={t('backToList')} crumbText={t('crumbRoot')} onBack={onBack} - icon={packageArtwork(pkg.name)} + icon={} actions={(
{pkg.installed 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 2226aa0476..d85336cf1a 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -285,6 +285,38 @@ describe('PluginManagerPage', () => { } }) + it('renders manifest icons for arbitrary bundles and rows, with decode fallback and source recovery', () => { + const icon = 'data:image/svg+xml;base64,PHN2Zy8+' + const updatedIcon = 'data:image/png;base64,cG5n' + const bundle = pkg({ meta: { icon }, rows: [row({ meta: { icon } }), row({ entryId: 'plain' as PluginEntryId, rowId: 'plain', moduleName: 'plain' })] }) + const { set } = renderTab({ packages: [bundle] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }) + const image = () => document.querySelector('[data-plugin-package] img, [data-plugin-detail] img')! + expect(image().getAttribute('src')).toBe(icon) + expect(image().getAttribute('alt')).toBe('') + expect(image().width).toBe(36) + fireEvent.error(image()) + expect(document.querySelector('[data-plugin-package] img')).toBeNull() + expect(document.querySelector('[data-plugin-package] svg')).not.toBeNull() + set({ packages: [{ ...bundle, meta: { icon: updatedIcon } }] }) + expect(image().getAttribute('src')).toBe(updatedIcon) + set({ packages: [bundle] }) + expect(image().getAttribute('src')).toBe(icon) + fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' })) + expect(image().getAttribute('src')).toBe(icon) + const rowImage = document.querySelector('[data-plugin-row] img')! + expect(rowImage.getAttribute('src')).toBe(icon) + expect(rowImage.width).toBe(30) + expect(document.querySelector('[data-plugin-row="plain"] img')).toBeNull() + expect(document.querySelector('[data-plugin-row="plain"] svg')).not.toBeNull() + fireEvent.error(rowImage) + expect(document.querySelector('[data-plugin-row] img')).toBeNull() + expect(document.querySelector('[data-plugin-row] svg')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Configure dsh-better-sidebar' })) + const detailImage = document.querySelector('[data-plugin-row-detail] img')! + expect(detailImage.getAttribute('src')).toBe(icon) + expect(detailImage.width).toBe(36) + }) + it('shows metadata diagnostics without blocking management or displaying legacy descriptions', () => { const error = 'locale/zh.json: invalid title' const { actions, set, setLanguage } = renderTab({ diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d8df0b30d6..e6884489fc 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: 51ad53a79a676f013b61213764479a1247de522c -README.zh.md: c41d19af1855c8ab1582fa92b956ef283d3466cf +README.md: f17f80d877a76d61b55b502edb05781f8e05cee4 +README.zh.md: 8540a82389b4dced4e6b6048980f4278ec5722d7 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 51ad53a79a..f17f80d877 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -57,7 +57,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | -| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | | `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The filename grammar selection and lazy line-token highlighter shared by code preview and diff review. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index c41d19af18..8540a82389 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -57,7 +57,7 @@ kind: "package-library" | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | -| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | | `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的文件名 grammar 选择和惰性逐行 token 高亮。 | diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 2ad31f82b4..d98ad76158 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -83,5 +83,5 @@ export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' export * from './icons/index.tsx' export { - PluginArtworkTeam, PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault, + PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault, } from './plugin-artwork.tsx' diff --git a/packages/client/ui-primitives/src/plugin-artwork.tsx b/packages/client/ui-primitives/src/plugin-artwork.tsx index ec54304d3c..c5a774ca81 100644 --- a/packages/client/ui-primitives/src/plugin-artwork.tsx +++ b/packages/client/ui-primitives/src/plugin-artwork.tsx @@ -12,25 +12,6 @@ import type { IconProps } from './icons/props.ts' */ const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}` -/** Agent-team plugin artwork (four interlocking quadrants). */ -export const PluginArtworkTeam = ({ size = 36, className }: IconProps) => { - const uid = useArtworkId() - return ( - - - - - - - - - - - - - ) -} - /** Terminal plugin artwork (prompt chevron and cursor bar). */ export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx index 0df9c9a573..5db7f79cb8 100644 --- a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx +++ b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx @@ -2,13 +2,12 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { - PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal, + PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) const artworks = { - PluginArtworkTeam, PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index a485e99405..2f86bc5b7c 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: cf8bb5a75aa90842108646b57ceec67ada8ae711 -README.zh.md: 7df3eee13df7bd041cc5f09288d1b7fe28acded6 +README.md: ebdb22adc071b5df435fddd6620dd982d866aa36 +README.zh.md: 0bb188ea5024b14e68baa1a687a654ee8963f3d7 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index cf8bb5a75a..ebdb22adc0 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -103,6 +103,7 @@ None; this package neither assembles nor sends a provider request. These limits define what the shell itself provides versus what features must supply; they are current package constraints. - **Additional General rows require their feature plugins** — the shell supplies Developer tools; feature plugins supply the remaining preferences. +- **The Windows caption badge keeps a side-opening bubble** — `DesktopUpdateBadge` occupies `sidebar.toggle.badge` in the caption and requests `side="right"`, so the Desktop-owned menu text can cover its bubble while the sidebar is collapsed on Windows; the sidebar toggle and New Session bubbles open below the caption instead (#4688). ### Dev Note diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 7df3eee13d..0bb188ea50 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -103,6 +103,7 @@ kind: "package-reference" 这些限制说明外壳自身提供什么、功能包必须提供什么;它们是当前包约束。 - **额外的通用设置行需要相应功能插件**:外壳提供开发者工具;功能插件提供其他偏好。 +- **Windows 顶栏徽标的气泡仍向右展开**:`DesktopUpdateBadge` 占用顶栏的 `sidebar.toggle.badge` 且请求 `side="right"`,侧栏收起时其气泡会被 Desktop 菜单文字遮挡;侧栏开关与新建会话的气泡则改在顶栏下方展开(#4688)。 ### 开发备注 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index bbf984fb20..5358b03f67 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -178,13 +178,20 @@ font-weight: 600; } +/* The Loader entry id keeps one size in the card subtitle and the row it expands into; + the collapsed subtitle stays on the quieter label tier, while the expanded row carries + the card's primary value. */ +.cardIdentity, +.entryValue { + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; +} + .cardIdentity { display: block; width: 100%; color: var(--dsw-alias-label-tertiary); - font-family: var(--ds-font-family-code); - font-size: 11px; - line-height: 16px; } .cardTrailing { @@ -383,9 +390,6 @@ display: block; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); - font-family: var(--ds-font-family-code); - font-size: 12px; - line-height: 18px; } .details { diff --git a/packages/client/ui-sidebar-browser/README.i18n.yaml b/packages/client/ui-sidebar-browser/README.i18n.yaml index 533b9d85e2..455edd84ea 100644 --- a/packages/client/ui-sidebar-browser/README.i18n.yaml +++ b/packages/client/ui-sidebar-browser/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-browser/README.md -README.md: 61de40ef98c72501c20c56cffb1075322bcb4886 -README.zh.md: 8bdc88a85f2473e62c640e509db53577c1f2e6d4 +README.md: dcfaccdfb85cdada9300aadb2f6e9058dc5a95bc +README.zh.md: 8d5c34ab4c1b4d87e2a25912f38f102442f7a454 diff --git a/packages/client/ui-sidebar-browser/README.md b/packages/client/ui-sidebar-browser/README.md index 61de40ef98..dcfaccdfb8 100644 --- a/packages/client/ui-sidebar-browser/README.md +++ b/packages/client/ui-sidebar-browser/README.md @@ -25,7 +25,7 @@ Browse HTTP(S) pages, including loopback services, inside independent right-Side ## Use this package -The shipped Web and Desktop compositions already mount this package. Open **Browser** from the right-Sidebar guide, enter an HTTP(S) URL, or select an HTTP(S) link in Assistant Markdown. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or message-link activation creates another Browser tab. +Browser is disabled by default in Web profiles and enabled on Desktop. Enable the shipped entry through the Web profile patch to use it. Open **Browser** from the right-Sidebar guide and enter an HTTP(S) URL. Chat HTTP(S) links open here when the [link preference](../ui-chat/README.md) selects the built-in browser. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or delegated message-link activation creates another Browser tab. ### When to choose it @@ -33,11 +33,11 @@ Choose Browser for a Web page that should remain beside the current Session. Cho ### Minimal configuration -The package has no configuration. A custom Web composition mounts its Host companion; the Client loader then discovers the browser entry declared by the package manifest: +The package has no plugin configuration. A Web profile enables the shipped entry through its profile patch: ```yaml - id: ui-sidebar-browser - name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + disabled: false ``` Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`. The optional URL passes the same validation as address-bar input before navigation. diff --git a/packages/client/ui-sidebar-browser/README.zh.md b/packages/client/ui-sidebar-browser/README.zh.md index 8bdc88a85f..8d5c34ab4c 100644 --- a/packages/client/ui-sidebar-browser/README.zh.md +++ b/packages/client/ui-sidebar-browser/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -随附的 Web 与 Desktop composition 已挂载本包。可以从右侧 Sidebar guide 打开 **浏览器**、输入 HTTP(S) URL,或点击 Assistant Markdown 中的 HTTP(S) 链接。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或消息链接操作都会创建一个新的 Browser tab。 +Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用户可通过 profile patch 启用随附条目。可以从右侧 Sidebar guide 打开 **浏览器**并输入 HTTP(S) URL。Chat 的[链接偏好](../ui-chat/README.zh.md)选择内置浏览器时,HTTP(S) 链接会在此打开。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或委托到此的消息链接操作都会创建一个新的 Browser tab。 ### 何时选择 @@ -33,11 +33,11 @@ kind: "package-reference" ### 最小配置 -本包没有配置字段。自定义 Web composition 挂载 Host companion;随后 Client loader 会发现 package manifest 声明的浏览器入口: +本包没有插件配置字段。Web profile 通过其 profile patch 启用随附条目: ```yaml - id: ui-sidebar-browser - name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + disabled: false ``` Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url } })` 打开 tab。可选 URL 会在导航前接受与地址栏输入相同的校验。 diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index c837437b74..72b5b20666 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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/README.md -README.md: 1c5306b8b3efa5d7908cfd33206acefef20fc3a8 -README.zh.md: d72da040252f391bcc1468c9e29adf6a5ef85ef0 +README.md: 94bb3a70ca8c06b5838f41436bb0f2ba2751501e +README.zh.md: 86f54882ba1c96b9b50898993931757f7afef4e5 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 1c5306b8b3..94bb3a70ca 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -41,7 +41,7 @@ The top expand button hosts the optional, non-interactive `sidebar.toggle.badge` During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. -On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. +On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. The sidebar toggle and New Session bubbles open below the caption, where the Desktop-owned menu text cannot cover them; an occupying `sidebar.toggle.badge` chooses its own bubble side. ### macOS desktop diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index d72da04025..86f54882ba 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -41,7 +41,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。 -在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。 +在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。侧栏开关与新建会话的悬停提示在顶栏下方展开,Desktop 菜单文字不会盖住它们;占用 `sidebar.toggle.badge` 的控件自行决定气泡展开方向。 ### macOS 桌面 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f58588feb1..12bcbe8ca8 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -107,6 +107,10 @@ export function SidebarRoot({ }, [collapsed]) const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar') const wide = windowsTitlebar ? !collapsed : !collapsed || !settled + // The Windows caption menus occupy the strip to the right of these controls + // (that is what --dsh-windows-menu-start reserves), so a right-side bubble + // lands under their text. Below the caption is the only clear side. + const captionTooltipSide = windowsTitlebar ? 'bottom' : 'right' // Freeze the content at its expanded width while it fades out (collapsed // && wide): the sliding column then clips it instead of reflowing it. The @@ -169,7 +173,7 @@ export function SidebarRoot({ // (the expand affordance, figma sidebar-hover flow). Expanded it is a plain // panel icon. const toggle = ( - +
{t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')} + {t(matched.reason === 'unknown' ? 'readonly.unknown.body' : oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 0055ea8c3f..d0779cddc8 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -37,6 +37,7 @@ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRig function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null + if (subagent.address.mode === 'unknown') return { reason: 'unknown' } if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } // Until a Host summary establishes parent availability, keep the normal // disabled composer instead of claiming that the parent is offline. diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index cc966d411c..c17a2f3cb1 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -24,6 +24,8 @@ export const zh = { 'retry': '重试', 'mode.oneShot': '一次性', 'mode.continuable': '可继续', + 'mode.unknown': '模式未知', + 'readonly.unknown.body': '读取子会话后才能确定是否可继续。', 'activity.running': '正在运行', 'activity.completed': '已完成', 'activity.inactive': '当前未运行', @@ -64,6 +66,8 @@ export const en: Record = { 'retry': 'Retry', 'mode.oneShot': 'one-shot', 'mode.continuable': 'continuable', + 'mode.unknown': 'unknown mode', + 'readonly.unknown.body': 'Read the child session to determine whether it can be continued.', 'activity.running': 'running', 'activity.completed': 'completed', 'activity.inactive': 'not running', diff --git a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx index a4fe489714..fde31ff638 100644 --- a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -73,7 +73,7 @@ export function parseSubagentChatAddress(value: string): SubagentAddress | undef if (parts.length !== 2 || parts[0] !== 'session') return undefined const parentSessionId = url.searchParams.get('parent') const mode = url.searchParams.get('mode') - if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable')) { + if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable' && mode !== 'unknown')) { return undefined } try { diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 49b2ac6016..a8e87cb283 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -152,6 +152,8 @@ describe('apply', () => { // One-shot stays read-only even while running: it has no stop action. expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true))) .toEqual({ reason: 'one-shot' }) + expect(select(owner({ address: { ...address, mode: 'unknown' }, parentAvailable: true }))) + .toEqual({ reason: 'unknown' }) expect(select(owner({ address }))).toBeNull() expect(select(owner({ address, parentAvailable: true }))).toBeNull() expect(select(owner({ address, parentAvailable: false }))) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 626026fe54..44fad0a0b1 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -25,7 +25,7 @@ const CHILD = 'child' as SessionId const GRANDCHILD = 'grandchild' as SessionId const t: SubagentHeaderLineageProps['t'] = makeTranslate(zh) -type CatalogFixture = { entries: readonly SubagentCatalogRow[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] } +type CatalogFixture = { entries: readonly (SubagentCatalogRow | { id: SessionId; mode: 'unknown'; label?: string; activity: 'inactive' })[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] } function catalog(over: Partial = {}): CatalogFixture { return { @@ -402,6 +402,16 @@ describe('SubagentHeaderLineage', () => { }) }) + it('keeps unknown catalog children clickable by their durable parent address', () => { + const input = props(catalog({ entries: [{ id: CHILD, mode: 'unknown', activity: 'inactive' }] })) + render() + hoverCatalog(screen.getByRole('button', { name: /子代理/ })) + const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) }) + expect(row.textContent).toContain('模式未知') + fireEvent.keyDown(row, { key: 'Enter' }) + expect(input.openChild).toHaveBeenCalledWith({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'unknown' }) + }) + it('shows durable completion and token totals, ticks active duration, and freezes inactive rows', async () => { const now = 2_000_000_000_000 const minute = 60_000 @@ -926,6 +936,11 @@ describe('SubagentReadOnlyComposer', () => { expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') }) + it('keeps an unknown child read-only until its descriptor is available', () => { + render() + expect(screen.getByRole('status').textContent).toContain('读取子会话后才能确定是否可继续') + }) + it('explains that one-shot histories never accept follow-ups', () => { render() expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息') diff --git a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx index 143a0cb595..0348f9c786 100644 --- a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx +++ b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx @@ -38,6 +38,8 @@ describe('Sidebar chat address', () => { expect(parseSubagentChatAddress(resource)).toEqual(ADDRESS) expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'one-shot' }))) .toEqual({ ...ADDRESS, mode: 'one-shot' }) + expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'unknown' }))) + .toEqual({ ...ADDRESS, mode: 'unknown' }) }) it.each([ @@ -47,7 +49,7 @@ describe('Sidebar chat address', () => { 'dsh-resource://subagentchat/other/child?parent=parent&mode=continuable', 'dsh-resource://subagentchat/session/child?mode=continuable', 'dsh-resource://subagentchat/session/child?parent=&mode=continuable', - 'dsh-resource://subagentchat/session/child?parent=parent&mode=unknown', + 'dsh-resource://subagentchat/session/child?parent=parent&mode=invalid', 'dsh-resource://subagentchat/session/%?parent=parent&mode=continuable', ])('rejects %s', (address) => { expect(parseSubagentChatAddress(address)).toBeUndefined() diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index 12faf98360..9f2ca6ce41 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/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/experimental/agent-team-profile/README.md -README.md: 2d94b4d1c0e7de9079ac28945f1e79594b17bb76 -README.zh.md: b1126a266c472d5548b0adbc4139d701ac5b4f6f +README.md: 053f44b673552e7bb7d63ed944652ea6fb87145a +README.zh.md: bd6837339298fc13908e077833f75aa9c3a6696e diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index 2d94b4d1c0..053f44b673 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -48,7 +48,7 @@ In an existing profile’s `package.json`, keep `@deepseek-ai/dsh-experimental-a The layer adds the Agent Teams domain and its scoped creation, roster, messaging, interruption, waiting, and task-board tools. Direct delegation uses `spawn_teammate`, which supports fresh and fork context. The `subagent` and `subagent_fork` tools and overlapping global child controls are disabled. Workflow retains the base profile’s `spawn` provider, while the underlying Subagent services and both providers remain available to teammates and workflow. -In Web and Desktop conversations, the [Team UI](../client-ui-agent-team/README.md) displays the member roster and shared task board and opens teammate sessions. The same bundle switch controls the tools and browser UI. +In Web and Desktop conversations, the [Team UI](../client-ui-agent-team/README.md) displays the member roster and shared task board and opens teammate sessions. The same bundle switch controls the tools and browser UI. The Plugins page reads the bundle's [icon](icon.svg) from its `package.json.icon` declaration, including while the bundle is disabled. ----- diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index b1126a266c..bd68373392 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -48,7 +48,7 @@ dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile 本层会添加 Agent Teams domain,以及 Team-scoped 创建、roster、消息、interrupt、等待与任务板工具。直接委派使用支持 fresh 和 fork 上下文的 `spawn_teammate`。`subagent`、`subagent_fork` 工具和名称重叠的全局 child control 均被禁用。Workflow 保留 base profile 的 `spawn` 提供方,底层 Subagent 服务和两个提供方仍供 teammate 与 workflow 使用。 -在 Web 和 Desktop 会话中,[Team UI](../client-ui-agent-team/README.zh.md) 显示成员列表与共享任务看板,并可打开成员会话。同一个组合包开关控制工具与浏览器 UI。 +在 Web 和 Desktop 会话中,[Team UI](../client-ui-agent-team/README.zh.md) 显示成员列表与共享任务看板,并可打开成员会话。同一个组合包开关控制工具与浏览器 UI。插件页通过组合包的 `package.json.icon` 声明读取其[图标](icon.svg),组合包禁用时也会显示。 ----- diff --git a/packages/experimental/agent-team-profile/icon.svg b/packages/experimental/agent-team-profile/icon.svg new file mode 100644 index 0000000000..488848231e --- /dev/null +++ b/packages/experimental/agent-team-profile/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 73999ee5d4..fcc06fcf37 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -2,6 +2,7 @@ "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Agent Teams collaboration, tools, and Web UI in one experimental bundle", "version": "0.1.6-alpha.2", + "icon": "./icon.svg", "publishConfig": { "access": "public" }, @@ -24,6 +25,7 @@ "./package.json": "./package.json" }, "files": [ + "icon.svg", "locale/*.json", "lib/index.js", "cordis.patch.yml", diff --git a/packages/experimental/webworker-packer/tests/preview.spec.ts b/packages/experimental/webworker-packer/tests/preview.spec.ts index 2c5c381059..f3b5937ab9 100644 --- a/packages/experimental/webworker-packer/tests/preview.spec.ts +++ b/packages/experimental/webworker-packer/tests/preview.spec.ts @@ -65,7 +65,7 @@ describe('Node preparation of Preview Session data', () => { expect(packVfsOverlay(fixture.trees).files).toEqual(original.files) }) - it.each(['missing-descriptor', 'other-root', 'other-origin', 'current-child'] as const)('adds catalog facts only for supported direct subagent children in the same root: %s', (mode) => { + it.each(['missing-descriptor', 'other-root', 'other-origin', 'current-child'] as const)('catalogs direct subagent children in the same root, retaining missing descriptors as unknown: %s', (mode) => { const fixture = source() const parent = fixture.put(3) const childDirectory = join(fixture.trees[0]!.directory, 'sessions', mode === 'other-root' ? 'other' : 'project', 'child') @@ -84,8 +84,12 @@ describe('Node preparation of Preview Session data', () => { const packed = packPreviewFixture(fixture.trees) const restored = restore(packed.files[`home/sessions/project/example/${sessionFormatLogFilename(currentVersion)}`]) expect(restored.inheritedEventCount).toBe(0) - expect(restored.events).toEqual(mode === 'current-child' ? [{ type: 'subagent/catalog', seq: 0, time: 1, - data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' } }] : []) + const catalog = mode === 'current-child' + ? [{ version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' }] + : mode === 'missing-descriptor' + ? [{ version: 1, childId: 'child', childCreatedAt: 2, mode: 'unknown' }] + : [] + expect(restored.events).toEqual(catalog.map(data => ({ type: 'subagent/catalog', seq: 0, time: 1, data }))) for (const [path, bytes] of Object.entries(original.files)) expect(packed.files[path], path).toEqual(bytes) expect(readFileSync(parent)).toEqual(before) expect(readFileSync(childPath)).toEqual(childBefore) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 76a69a3cdf..b540e06b33 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1988,6 +1988,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'settings.section\' (client-ui-settings-general), so it exists while that entry is mounted', occupants: [ 'client-locale LanguageRow id \'language\'', + 'client-ui-chat LinkOpeningRow id \'link-opening\'', 'client-ui-chat PerformanceUsageRow id \'performance-usage\'', 'client-ui-chat TranscriptViewRow id \'transcript-view\'', 'client-ui-conversation EnterBehaviorRow id \'composer-enter\'', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index eed58061e6..69e75dd00b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5365,7 +5365,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PluginLocalizedMeta', - declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly error?: string;\n}', + declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly icon?: string;\n readonly error?: string;\n}', }, { name: 'PluginRegistries', @@ -5745,7 +5745,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionAddress', - declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\';\n};', + declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\' | \'unknown\';\n};', }, { name: 'SessionAssistantStreamAttempt', @@ -6429,7 +6429,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentCatalogEntry', - declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n});', + declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n} | {\n readonly mode: \'unknown\';\n readonly label?: string;\n});', }, { name: 'SubagentCatalogRow', diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 0fb2eafad1..10a1a431e4 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -647,6 +647,9 @@ describe('HarnessClient', () => { const inject = (method: string, params: Record): void => { (client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params }) } + const unknownChild = { type: 'subagent/catalog', seq: 0, time: 1, + data: { version: 1, childId: 'unreadable-child', childCreatedAt: 1, mode: 'unknown' } } + inject('session.event', { sessionId: 'root', event: unknownChild }) inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' }) inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' }) inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } }) @@ -658,6 +661,7 @@ describe('HarnessClient', () => { inject('subagent.started', { parentSessionId: '', childSessionId: 'x' }) inject('subagent.finished', { childSessionId: 'root' }) + expect(await tree.next()).toEqual({ method: 'session.event', params: { sessionId: 'root', event: unknownChild } }) expect((await tree.next()).method).toBe('subagent.started') expect((await tree.next()).method).toBe('subagent.started') expect((await tree.next()).params.sessionId).toBe('grandchild') diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 892da2ad10..afd45897dc 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/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/session/session-format-v3-to-v4/README.md -README.md: 2c2d3b4342d68cec5e9a9f94f969a380bbbf15f2 -README.zh.md: a92ae34a73f134253880fec3f931815e067bc344 +README.md: 7a1b188194b54ff3ba7730388126fa0d2164fa06 +README.zh.md: 9d0f48ac375501d7dbd1e0b914bb882e43b02ac8 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 2c2d3b4342..7a1b188194 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -144,17 +144,17 @@ There is no recursive source search. Captured request text, assistant replay sta |---|---| | One descriptor with version 1 | Require string provider and label; derive `mode: 'continuable'`. | | One descriptor with version 2 or 3 | Require string provider; use its mode and optional label under catalog rules. | -| Zero descriptors, or an unsupported descriptor version | May retain an existing parent entry; cannot create a missing entry. | -| More than one own descriptor | Retain an existing parent entry without mode/label comparison; do not create a missing entry. | +| Zero descriptors, or an unsupported descriptor version | Retain an existing parent entry; otherwise append unknown-mode membership. | +| More than one own descriptor | Retain an existing parent entry without mode/label comparison; otherwise append unknown-mode membership. | | Existing own parent entry | Retain it and its extensions; require matching child creation time and mode/label from exactly one supported own descriptor, when available. | | Missing own parent entry with complete supported evidence | Append a version-0 catalog fact with child id, creation time, mode, and optional label. | -| Missing own parent entry without complete evidence | Preserve the parent without inventing a catalog entry. | +| Missing own parent entry without complete evidence | Append a version-1 `subagent/catalog` with header identity and unknown mode, without inventing a label. | -Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot`, and a string label for continuable mode; a present one-shot label must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. +Catalog versions 0 and 1 require string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot` (version 1 additionally accepts `unknown`), and a string label for continuable mode; a present label in any mode must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. Unknown-mode entries retain header identity without asserting a supported child descriptor. The stage considers parent catalog records only after the final inherited cut. Every inherited marker discards earlier catalog candidates without interpreting their payloads. Missing entries append after all source events, sorted by creation time then child id, with dense new sequences. Their time is the final source event's time, or header creation time for an empty log. They neither enter the model surface nor change the inherited count. -Storage supplies the complete recognizable child set within its root and rechecks membership and physical revisions during preparation, memo reuse, and publication. Incomplete descriptor evidence only prevents that child’s backfill. Unreadable headers that prevent classifying membership, unsupported selected generations, or source drift refuse the operation. The package itself reads no files; [persistence](../session-persistence-jsonl/README.md) owns encoding, locks, cancellation, and publication. +Storage supplies recognizable direct-child evidence and rechecks membership and physical revisions during preparation, memo reuse, and publication. The JSONL provider isolates unreadable child headers, child decoding failures, and invalid descriptor fields while retaining other catalog entries; it does not migrate child catalogs during parent preparation. The converter still rejects conflicting supplied facts and changed parent history. [JSONL persistence](../session-persistence-jsonl/README.md) owns warnings, source checks, and child-local failure handling. Current V4 reads never invoke this converter and validate native catalog fields, uniqueness, and delivery ownership directly. ### Sequence references and inheritance @@ -243,7 +243,7 @@ Current common admission does not validate each user/tool/developer content bloc | `compaction/start`, `compaction/summary`, `compaction/end` | Match compaction id, source command, and the active turn context. Summary spans name exact current-surface nodes and exclude the protected head; successful completion has one summary. Inherited unfinished compactions expire at the end-seed marker. | | `compaction/prune` | Its span names exact current-surface nodes and excludes the protected head; it does not require a compaction transaction or its owner fields. | | Compact checkpoint replacement | Its `compact-checkpoint` source identifies the active compaction. | -| Native `subagent/catalog` | Check own version-0 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | +| Native `subagent/catalog` | Check own version-0/version-1 payload fields and unique child ids after the inherited cut. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | | Inherited cut and delivery | Apply the marker, coordinate, and generation-ownership rules stated above. | These checks are generation-owned in [relationships.ts](src/relationships.ts). Full common message/envelope acceptance and plugin-owned message projections additionally use the installed Session; the exported V4 restorer alone is not a replacement for complete catalog restoration. @@ -334,7 +334,7 @@ The edge preserves the recorded request prefix. Provider cache availability and - **Nested historical tool results** — migration currently refuses results containing another tool-result wrapper. The original generation remains intact and no V4 successor is published. A later converter may support evidenced source cases without changing the established V4 format; the [migration cookbook](../../../docs/cookbook/adding-a-session-format-version.md#stages-and-validation) defines that distinction. - **Historical extension consumers** — prefixed message and result fields preserve JSON data without activating core fields. A consumer must explicitly understand those fields before interpreting them. - **Retained child logs required** — a parent alone cannot recover unrecorded child ids, creation times, or descriptors. Deleted children cannot be reconstructed from tool arguments; existing parent catalog records remain. -- **Missing historical catalog entries** — without exactly one supported own descriptor, an absent parent entry is not backfilled. The child log remains readable by id; current V4 reads do not rescan children to repair that omission. +- **Unknown historical modes** — without exactly one supported own descriptor, a missing parent entry records unknown mode. Current reads do not rewrite that entry; opening the child resolves available descriptor information or reports its own error. - **Storage scope** — facts cover recognizable children within the same persistence root. Cross-root import and corrupt-log repair are outside this migration. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index a92ae34a73..9d0f48ac37 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -144,17 +144,17 @@ V3 未知内容标签变为 `plugin:`,其他字段原样保留 |---|---| | 一个 version 1 descriptor | 要求字符串 provider 与 label;导出 `mode: 'continuable'`。 | | 一个 version 2 或 3 descriptor | 要求字符串 provider;按目录规则使用其 mode 和可选 label。 | -| 没有 descriptor,或 descriptor 版本不受支持 | 可以保留已有父目录项;不能创建缺失项。 | -| 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;不创建缺失项。 | +| 没有 descriptor,或 descriptor 版本不受支持 | 保留已有父目录项;否则追加模式未知的目录项。 | +| 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;否则追加模式未知的目录项。 | | 已有自身父目录项 | 保留条目及其扩展;要求子创建时间一致,并在恰好一个受支持的自身 descriptor 可用时比较其 mode/label。 | | 缺少自身父目录项,且受支持证据完整 | 追加带 child id、创建时间、mode 和可选 label 的 version-0 目录事实。 | -| 缺少自身父目录项,且证据不完整 | 保留父 Session,不凭空创建目录项。 | +| 缺少自身父目录项,且证据不完整 | 追加 version-1 `subagent/catalog`,记录 header 身份和未知模式,不编造标签。 | -Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode,以及 continuable mode 下的字符串 label;存在的 one-shot label 也必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。 +Catalog version 0 和 1 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode(version 1 还接受 `unknown`),以及 continuable mode 下的字符串 label;任何 mode 下存在的 label 都必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。未知模式条目保留 header 身份信息,不声明子 descriptor 受支持。 Stage 只把最终继承截点之后的父目录记录作为候选。每个 inherited marker 都会丢弃更早的目录候选,不解释其载荷。缺失项追加在所有源事件之后,按创建时间、child id 排序,并使用连续的新序号。时间取最后一个源事件的 time;空日志则取 header 创建时间。这些记录既不进入模型表面,也不改变继承计数。 -存储层提供其根目录内完整的可识别子级集合,并在准备、复用 memo 与发布时复查成员及物理版本。不完整的 descriptor 证据只阻止补填对应子 Session 的目录项。无法据此判断成员关系的不可读 header、不受支持的所选代际或源变化都会拒绝操作。本包不读取文件;[持久化层](../session-persistence-jsonl/README.zh.md)负责编码、锁、取消与发布。 +存储提供可识别的直属子证据,并在准备、memo 复用与发布时重新检查成员集合和物理修订。JSONL 提供方隔离不可读子 header、子日志解码失败和无效 descriptor 字段,同时保留其他目录项;准备父目录时不会迁移子目录。转换器仍拒绝冲突的已提供事实和发生变化的父历史。[JSONL 持久化](../session-persistence-jsonl/README.zh.md)拥有警告、来源检查与子会话错误隔离。当前 V4 读取不调用本转换器,直接校验原生目录字段、唯一性及投递归属。 ### 序号引用与继承 @@ -243,7 +243,7 @@ System image 接纳要求非空 attachment id、PNG/JPEG/WebP/GIF MIME 类 | `compaction/start`、`compaction/summary`、`compaction/end` | 匹配 compaction id、源 command 和活动 turn 上下文。Summary 区间引用精确的当前表面节点且排除 protected head;成功完成需要一个 summary。继承的未完成 compaction 在 end-seed marker 处过期。 | | `compaction/prune` | 其区间引用精确的当前表面节点且排除 protected head;它不要求存在 compaction 事务或其所有者字段。 | | Compact checkpoint 替换 | 其 `compact-checkpoint` 来源标识活动 compaction。 | -| 原生 `subagent/catalog` | 检查继承截点之后的自身 version-0 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 | +| 原生 `subagent/catalog` | 检查继承截点之后的自身 version-0/version-1 载荷字段与 child id 唯一性。原生读取既不收集子日志,也不比较其物理事实;继承项不建立自身成员关系。 | | 继承截点与 delivery | 应用上文的 marker、坐标及代际归属规则。 | 这些检查由 [relationships.ts](src/relationships.ts) 按代际拥有。完整的通用消息/信封接纳与插件拥有的消息投影还使用已安装 Session;单独的导出 V4 恢复器不能替代完整 catalog 恢复。 @@ -334,7 +334,7 @@ Fork 种子构造归核心 Session 所有,不属于此迁移。原生 V4 接 - **历史嵌套工具结果**——当前迁移拒绝包含另一个 tool-result wrapper 的结果。原始代际保持完整,且不发布 V4 successor。后续转换器可以支持有证据的源数据场景,而不改变既定 V4 格式;[迁移 cookbook](../../../docs/cookbook/adding-a-session-format-version.zh.md#stages-and-validation) 定义了这一区别。 - **历史扩展消费者**——带前缀的消息与结果字段保留 JSON 数据,不激活核心字段。消费者必须明确理解这些字段后才能解释它们。 - **依赖保留的子日志**——仅凭父日志无法恢复未记录的子 id、创建时间或 descriptor。删除的子 Session 无法从工具参数恢复;已存在的父目录记录仍保留。 -- **历史目录项缺失**——没有恰好一个受支持的自身 descriptor 时,不补填父目录缺失项。子日志仍可按 id 读取;当前 V4 读取不会重新扫描子日志来补齐该条目。 +- **历史模式未知**——没有恰好一个受支持的自身 descriptor 时,缺失的父目录项记录未知模式。当前读取不重写该项;打开子会话时解析可用的 descriptor 信息,或报告该子会话的错误。 - **存储范围**——事实只覆盖同一持久化根目录内可识别的子 Session。跨根目录导入和损坏日志修复不属于此迁移。 diff --git a/packages/session/session-format-v3-to-v4/src/facts.ts b/packages/session/session-format-v3-to-v4/src/facts.ts index a08f2bda20..aa99fd32fa 100644 --- a/packages/session/session-format-v3-to-v4/src/facts.ts +++ b/packages/session/session-format-v3-to-v4/src/facts.ts @@ -6,7 +6,8 @@ import type { SessionFormatArtifact, SessionFormatJsonObject, SessionFormatJsonV /** * Collect a child's own descriptor without requiring one before its parent catalog is read. * @param artifact - validated historical child artifact with its exact inherited cut. - * @returns compact child identity and descriptor evidence for catalog completion. + * @returns compact child identity and descriptor evidence with validated discovery fields for catalog completion. + * @throws SessionFormatError when a supported own descriptor has invalid discovery fields. */ export function historicalChildCatalogSource(artifact: SessionFormatArtifact): SessionFormatJsonObject { const header = artifact.header @@ -14,10 +15,12 @@ export function historicalChildCatalogSource(artifact: SessionFormatArtifact): S throw new SessionFormatUnsupportedMigrationError('catalog migration requires a subagent child with a direct parent') } const descriptors = artifact.events.filter(event => event.type === 'subagent/descriptor' && event.seq >= artifact.inheritedEventCount) - return { + const source = { childId: header.id, childCreatedAt: header.createdAt, descriptorCount: descriptors.length, descriptor: descriptors[0]?.data ?? null, } + childCatalogFact(source) + return source } /** @@ -49,6 +52,9 @@ export function childCatalogFact(source: SessionFormatJsonObject): SessionFormat if (typeof descriptor['provider'] !== 'string') { throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} has an invalid subagent descriptor provider`) } + if (descriptor['version'] !== 1 && descriptor['mode'] !== 'continuable' && descriptor['mode'] !== 'one-shot') { + throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} has an invalid subagent descriptor mode`) + } return catalogFact({ version: 0, childId: id, childCreatedAt: source['childCreatedAt'] as number, // V1 described only continuable children and carried no mode field. @@ -64,12 +70,13 @@ export function childCatalogFact(source: SessionFormatJsonObject): SessionFormat * @returns validated catalog payload. */ export function catalogFact(value: SessionFormatJsonValue, subject = 'subagent/catalog'): SessionFormatJsonObject { - if (!isSessionFormatJsonObject(value) || value['version'] !== 0 + if (!isSessionFormatJsonObject(value) || (value['version'] !== 0 && value['version'] !== 1) || typeof value['childId'] !== 'string' - || (value['mode'] !== 'continuable' && value['mode'] !== 'one-shot') + || (value['mode'] !== 'continuable' && value['mode'] !== 'one-shot' && value['mode'] !== 'unknown') + || (value['version'] === 0 && value['mode'] === 'unknown') || (value['mode'] === 'continuable' && typeof value['label'] !== 'string') || (value['label'] !== undefined && typeof value['label'] !== 'string')) { - throw new SessionFormatError(`${subject} requires a complete version 0 catalog fact`) + throw new SessionFormatError(`${subject} requires a supported versioned catalog fact`) } sessionFormatCount(value['childCreatedAt'], 'catalog child creation time') return value diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index 3b1c1de60a..c2a28e04b2 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -122,9 +122,10 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } continue } - if (fact === undefined) continue - existingCatalogs.set(id, fact) - context.emitEvent({ type: 'subagent/catalog', seq: this.nextSeq++, time: this.time, data: fact }) + const entry = fact ?? { version: 1, childId: id, childCreatedAt: source['childCreatedAt'] as number, mode: 'unknown' } + existingCatalogs.set(id, entry) + context.emitEvent({ type: 'subagent/catalog', + seq: this.nextSeq++, time: this.time, data: entry }) } return cut } diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index 7b1ad7e3c0..455535ed79 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -67,14 +67,14 @@ describe('V3 parent catalog completion', () => { expect(migrate(source, [child], true).events).toEqual([ ...source, { type: 'subagent/catalog', seq: 4, time: 2, data: child }, ]) - expect(() => migrate([...source, { ...opaque, seq: 4 }], [], true)).toThrow('complete version 0') - expect(() => migrate([opaque], [])).toThrow('complete version 0') + expect(() => migrate([...source, { ...opaque, seq: 4 }], [], true)).toThrow('supported versioned') + expect(() => migrate([opaque], [])).toThrow('supported versioned') }) it('requires explicit corpus facts and refuses conflicting identities', () => { expect(() => sessionFormatV3ToV4.createStage({ sourceHeader: header, targetHeader: { ...header, version: 4 }, sourceKind: 'decoded', sourceInheritedEventCount: 0 })).toThrow('explicit historical child facts') - expect(() => migrate([], [{ ...child, mode: 'unknown' }])).toThrow('complete version 0') + expect(() => migrate([], [{ ...child, mode: 'invalid' }])).toThrow('supported versioned') expect(() => migrate([{ type: 'subagent/catalog', seq: 0, time: 2, data: child }], [{ ...child, childCreatedAt: 3 }])).toThrow('conflicts') }) @@ -88,8 +88,8 @@ describe('V3 parent catalog completion', () => { ], } expect(childCatalogFact(historicalChildCatalogSource(artifact))).toEqual(child) - expect(migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })]).events).toEqual([]) - expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })]).events).toEqual([]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })]).events).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })]).events).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }]) }) it('orders tied and differently dated children independently of directory enumeration', () => { const children = [{ ...child, childId: 'z' }, { ...child, childId: 'a' }, { ...child, childId: 'first', childCreatedAt: 1 }] @@ -114,13 +114,13 @@ describe('V3 parent catalog completion', () => { expect(() => migrate([preceding, { ...delivery, data: { ...delivery.data, sessionFormatVersion: 3 } }], [])).toThrow('wrong Session') }) - it('skips unavailable descriptors while requiring complete discovery fields for new facts', () => { + it('retains unknown children while validating available descriptor fields', () => { const artifact = { header: { ...header, origin: 'subagent' as const, parentSession: 'parent' }, inheritedEventCount: 0, events: [] } expect(() => historicalChildCatalogSource({ ...artifact, header })).toThrow('direct parent') for (const data of [null, {}, { version: 4, mode: 'one-shot', provider: 'spawn' }]) { - expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toEqual([]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toMatchObject([{ data: { childId: 'parent', mode: 'unknown' } }]) } - for (const value of [null, { ...child, version: 1 }, { ...child, childId: null }, + for (const value of [null, { ...child, version: 2 }, { ...child, childId: null }, { ...child, label: null }, { ...child, childCreatedAt: -1 }]) { expect(() => migrate([], [value])).toThrow() } @@ -148,7 +148,8 @@ describe('V3 parent catalog completion', () => { const unavailable = { childId: 'unavailable', childCreatedAt: 1, descriptorCount: kind === 'missing' ? 0 : kind === 'multiple' ? 2 : 1, descriptor } expect(migrate([], [child, unavailable]).events).toEqual([ - { type: 'subagent/catalog', seq: 0, time: 1, data: child }, + { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 1, childId: 'unavailable', childCreatedAt: 1, mode: 'unknown' } }, + { type: 'subagent/catalog', seq: 1, time: 1, data: child }, ]) }) diff --git a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts index 2d3c447842..7142c6ab45 100644 --- a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts @@ -113,6 +113,22 @@ describe('restoreReleasedV4Artifact', () => { .toThrow(/unseeded format v4 Session has inherited events/) }) + it('retains unknown child membership and rejects invalid identities or duplicate child ids', () => { + const known = new Set([...KNOWN, 'subagent/catalog']) + const data = { version: 1, childId: 'unreadable', childCreatedAt: 2, mode: 'unknown' } + const event = { type: 'subagent/catalog', seq: 0, time: 1, data } + const valid = artifact([event]) + expect(restoreReleasedV4Artifact(valid, known)).toBe(valid) + expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: { ...data, label: 'retained' } }]), known)).not.toThrow() + for (const invalid of [null, { ...data, version: 0 }, { ...data, version: 2 }, { ...data, childId: 1 }, + { ...data, childCreatedAt: -1 }, { ...data, mode: 'invalid' }, { ...data, label: null }]) { + expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: invalid }]), known)).toThrow() + } + expect(() => restoreReleasedV4Artifact(artifact([event, { ...event, seq: 1 }]), known)).toThrow('duplicate catalog child') + expect(() => restoreReleasedV4Artifact(artifact([event, { type: 'subagent/catalog', seq: 1, time: 1, + data: { ...data, mode: 'one-shot' } }]), known)).toThrow('duplicate catalog child') + }) + it('validates only the envelope in physical mode', () => { const unknown = conversation() unknown.splice(2, 0, { type: 'campaign/unknown', seq: 0, time: 1, data: { x: 1 } }) diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 1589a1d5c7..4b42d7fa5e 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/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/session/session-persistence-jsonl/README.md -README.md: 96c333d9f845943ff44c8d5d539c40d07b9dc0f6 -README.zh.md: 71126dd909c92aabdee5c07d23505f708dc5e038 +README.md: 5846404961caaef7a91fb4dda562655d15fd2be9 +README.zh.md: b7dd646e57b75fe47f3683d2b230dace628f8eee diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 96c333d9f8..5846404961 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -81,7 +81,7 @@ The current-generation scanner applies the current codec owner’s structural ad `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. -Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Missing, unsupported, or multiple child descriptors prevent only that child’s catalog backfill; existing parent entries and child logs remain available. Invalid fields in exactly one supported own descriptor still report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events. +Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers, including corrupt Zstandard header frames, are omitted from discovery and `list()`. Direct access to a corrupt compressed header still rejects; header I/O and cancellation errors propagate. Child decoding and descriptor-field failures produce warnings naming the child path and retain header identity through `subagent/catalog` when the parent has no complete entry; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors likewise produce unknown-mode membership without inventing a label. Published unknown entries remain browsable; child history reads retry the actual log and resolve its mode from a valid descriptor. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current delivery ownership before exposing events. Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements). diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 71126dd909..b7dd646e57 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -81,7 +81,7 @@ kind: "package-reference" `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 -历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。子 descriptor 缺失、版本不受支持或存在多个时,只跳过该子 Session 的目录补填;已有父目录项和子日志仍可读取。恰好一个受支持的自身 descriptor 字段无效时,仍会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。 +历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header(包括损坏的 Zstandard header 帧)不参与发现,也不出现在 `list()` 中。直接访问损坏的压缩 header 仍会失败;header 的 I/O 错误与取消错误继续传播。子日志解码或 descriptor 字段失败会产生带子路径的警告;父目录没有完整条目时,通过 `subagent/catalog` 保留 header 身份信息。健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 同样生成模式未知的目录项,不编造标签。已发布的未知条目仍可浏览;读取子历史时会重试实际日志,并从有效 descriptor 确定模式。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。 历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。 diff --git a/packages/session/session-persistence-jsonl/src/catalog-migration.ts b/packages/session/session-persistence-jsonl/src/catalog-migration.ts index 77e3d8b43e..f0aefc057f 100644 --- a/packages/session/session-persistence-jsonl/src/catalog-migration.ts +++ b/packages/session/session-persistence-jsonl/src/catalog-migration.ts @@ -4,8 +4,7 @@ import { stat } from 'node:fs/promises' import { basename } from 'node:path' import { historicalSessionFormatCatalog, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog' import { historicalChildCatalogSource } from '@deepseek-ai/dsh-session-format-v3-to-v4' -import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' -import { SessionFormatError, SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format' +import { SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format' import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { parseGenerationLogFilename } from './format.ts' @@ -16,7 +15,8 @@ import type { JsonlPhysicalIdentity } from './generation.ts' /** Per-parent supplemental facts and the source checks required before serving or publishing them. */ interface PreparedCatalogFacts { readonly facts: readonly SessionFormatJsonObject[] - /** @returns resolves while every decoded child still has the captured physical revision. */ + readonly failures: readonly { readonly path: string; readonly error: unknown }[] + /** @returns resolves while every inspected child still has the captured physical revision. */ validate(): Promise } @@ -26,7 +26,7 @@ interface PreparedCatalogFacts { * @param sources - header-indexed direct children in the selected source corpus. * @param compression - configured source encoding. * @param signal - cancellation forwarded through each source read. - * @returns compact facts; complete child event arrays are released after extraction. + * @returns compact facts and child-local failures; complete child event arrays are released after extraction. */ export async function prepareCatalogFacts( parentId: SessionId, @@ -35,11 +35,18 @@ export async function prepareCatalogFacts( signal: AbortSignal, ): Promise { const facts: SessionFormatJsonObject[] = [] + const failures: { path: string; error: unknown }[] = [] const witnesses: { path: string; identity: JsonlPhysicalIdentity }[] = [] for (const source of sources) { signal.throwIfAborted() const version = parseGenerationLogFilename(basename(source.path), compression) if (version === undefined) throw new SessionFormatUnsupportedMigrationError(`unrecognized historical child generation ${source.path}`) + const witness: { path: string; identity: JsonlPhysicalIdentity } = { + path: source.path, identity: await stat(source.path, { bigint: true }), + } + witnesses.push(witness) + const unavailable = { childId: source.header.id, childCreatedAt: source.header.createdAt, + descriptorCount: 0, descriptor: null, sourcePath: source.path } let restored: Awaited> try { restored = await readDecodedJsonlSource(source.path, version, compression, { @@ -48,28 +55,31 @@ export async function prepareCatalogFacts( }), }, signal) } catch (error: unknown) { - if (error instanceof SessionFormatUnsupportedMigrationError) { - throw new SessionFormatUnsupportedError( - `child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`, - { kind: 'jsonl', path: source.path }, - ) - } - if (error instanceof SessionFormatError) { - throw new SessionPersistenceCorruptionError(`child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`, { cause: error }) - } - throw error + signal.throwIfAborted() + failures.push({ path: source.path, error }) + facts.push(unavailable) + continue } + witness.identity = restored.identity const header = restored.artifact.header if (header.id !== source.header.id || header.createdAt !== source.header.createdAt || header.parentSession !== parentId || header.origin !== 'subagent' || ['cwd', 'isSeeded', 'delegationDepth', 'agentPreset'].some(key => header[key] !== source.header[key as keyof SessionHeader])) { throw new JsonlGenerationSourceChangedError(source.path) } - facts.push({ ...historicalChildCatalogSource(restored.artifact), sourcePath: source.path }) - witnesses.push({ path: source.path, identity: restored.identity }) + let fact: SessionFormatJsonObject + try { + fact = historicalChildCatalogSource(restored.artifact) + } catch (error: unknown) { + failures.push({ path: source.path, error }) + facts.push(unavailable) + continue + } + facts.push({ ...fact, sourcePath: source.path }) } return { facts, + failures, async validate() { for (const witness of witnesses) { const current = await stat(witness.path, { bigint: true }) diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 6e7e7b5628..1a89b76d29 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -629,10 +629,13 @@ class JsonlSessionPersistence extends SessionPersistence { let prepared: Awaited> let validateRelatedSources: () => Promise try { - const children = async () => (await this.listArtifacts(signal, selected.sourcePath)) + const children = async () => (await this.listArtifacts(signal)) .filter(source => source.header.origin === 'subagent' && source.header.parentSession === id) const sources = await children() const related = await prepareCatalogFacts(id, sources, this.compression, signal) + for (const failure of related.failures) { + this.ctx.logger.warn(`${this.name}: session "${id}" catalog retained a child with unknown descriptor (raw log: ${failure.path}): ${String(failure.error)}`) + } const membership = sources.map(source => source.path).sort() validateRelatedSources = async () => { const current = (await children()).map(source => source.path).sort() @@ -1052,7 +1055,6 @@ class JsonlSessionPersistence extends SessionPersistence { private async listArtifacts( signal?: AbortSignal, - migrationSource?: string, ): Promise> { signal?.throwIfAborted() await this.ensureRootEncoding() @@ -1061,26 +1063,14 @@ class JsonlSessionPersistence extends SessionPersistence { const ids = new Set() for (const selected of await this.listGenerations(signal)) { signal?.throwIfAborted() - if (migrationSource !== undefined && selected.sourceVersion > SESSION_FORMAT_VERSION) { - throw new SessionFormatUnsupportedError( - `catalog migration cannot inspect Session format v${selected.sourceVersion}; upgrade the harness (raw log: ${selected.sourcePath})`, - { kind: 'jsonl', path: selected.sourcePath }, - ) - } let header: SessionHeader | undefined try { header = await this.readGenerationHeader(selected, undefined, signal) } catch (error: unknown) { - if (error instanceof SessionFormatUnsupportedError && migrationSource === undefined) continue + if (error instanceof SessionFormatUnsupportedError || error instanceof SessionPersistenceCorruptionError) continue throw error } if (header === undefined) { - if (migrationSource !== undefined && selected.sourcePath !== migrationSource) { - throw new SessionFormatUnsupportedError( - `cannot establish complete catalog membership from an unreadable Session header (raw log: ${selected.sourcePath})`, - { kind: 'jsonl', path: selected.sourcePath }, - ) - } continue } if (ids.has(header.id)) { @@ -1416,7 +1406,7 @@ class JsonlSessionPersistence extends SessionPersistence { } } - /** Read and validate only the independently compressed header frame. */ + /** Read only the header frame; compression failures reject as corruption, while I/O and cancellation propagate. */ private async readFirstZstdLine(path: string, signal?: AbortSignal): Promise { signal?.throwIfAborted() const handle = await open(path, 'r') @@ -1432,21 +1422,21 @@ class JsonlSessionPersistence extends SessionPersistence { signal?.throwIfAborted() content = Buffer.concat([content, chunk.subarray(0, bytesRead)]) signal?.throwIfAborted() - const first = scanZstdFrames(content, 1).frames[0] - signal?.throwIfAborted() - if (first === undefined) continue - let plaintext: Buffer try { + const first = scanZstdFrames(content, 1).frames[0] + if (first === undefined) continue + const plaintext = await decompressZstdFrame(content.subarray(first.start, first.end)) signal?.throwIfAborted() - plaintext = await decompressZstdFrame(content.subarray(first.start, first.end)) + assertZstdHeaderFrame(plaintext) + return plaintext.subarray(0, -1).toString('utf8') } catch (error) { /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ if (signal?.aborted) signal.throwIfAborted() - throw new Error('corrupt Zstandard session log: header frame failed validation', { cause: error }) + throw new SessionPersistenceCorruptionError( + `corrupt Zstandard session log: header frame failed validation: ${String(error)} (raw log: ${path})`, + { cause: error }, + ) } - signal?.throwIfAborted() - assertZstdHeaderFrame(plaintext) - return plaintext.subarray(0, -1).toString('utf8') } } finally { await handle.close() diff --git a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts index af86cdbfe6..6abb14c0db 100644 --- a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -14,6 +14,7 @@ const roots: string[] = [] const contexts: Context[] = [] afterEach(async () => { + vi.restoreAllMocks() await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) @@ -57,16 +58,36 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', await expect(f.ctx.sessionPersistence.open(f.parent, access)).rejects.toThrow('wrong Session') }) - it('does not publish incomplete membership after a child header becomes unreadable', async () => { - const f = await fixture() - const path = await f.write('child', [], true) - const broken = '{broken header\n' - await writeFile(path, compression === 'none' ? broken : await compressZstdFrame(broken)) - await expect(f.read()).rejects.toThrow(path) - await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(path) - expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) - }) + it.each(compression === 'none' ? ['json'] as const : ['json', 'magic', 'checksum', 'lines'] as const)( + 'opens the parent when another Session header has corrupt %s', async (damage) => { + const f = await fixture() + await f.write('child', [f.descriptor], true) + const path = await f.write('unrelated', []) + let damaged: Buffer + if (damage === 'json') { + damaged = compression === 'none' ? Buffer.from('{broken header\n') : await compressZstdFrame('{broken header\n') + } else if (damage === 'lines') { + damaged = await compressZstdFrame('{}\n{}\n') + } else { + damaged = await readFile(path) + const offset = damage === 'magic' ? 0 : damaged.length - 1 + damaged[offset] = damaged[offset]! ^ 0xFF + } + await writeFile(path, damaged) + expect((await f.ctx.sessionPersistence.list()).map(row => row.header.id).sort()).toEqual(['child', f.parent]) + const expected = [{ type: 'subagent/catalog', data: { childId: 'child', mode: 'continuable', label: 'old child' } }] + expect(await f.read()).toMatchObject(expected) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + try { expect((await writer.read()).events).toMatchObject(expected) } finally { await writer.close() } + expect(await readFile(generationLogPath(f.root, undefined, f.parent, 4, compression))).not.toHaveLength(0) + for (const access of ['read', 'write'] as const) { + await expect(f.ctx.sessionPersistence.open(SessionId('unrelated'), access)).rejects.toThrow() + } + expect(await readFile(path)).toEqual(damaged) + await expect(readFile(generationLogPath(f.root, undefined, SessionId('unrelated'), 4, compression))) + .rejects.toMatchObject({ code: 'ENOENT' }) + }, + ) it('invalidates lightweight revisions on child membership and contents while retaining stable tokens', async () => { const f = await fixture() @@ -114,34 +135,35 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).not.toBe(removed) }) - it('reports a corrupt child body at the child location', async () => { + it('isolates a corrupt child catalog and reports its path', async () => { const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn') const catalog = { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 0, childId: 'grandchild', childCreatedAt: 3, mode: 'one-shot' } } const path = await f.write('child', [catalog, { ...catalog, seq: 1 }], true, 4) - await expect(f.read()).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) - await expect(f.read()).rejects.toThrow(path) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(path)) + await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) }) - it.each(['read', 'write'] as const)('locates physical child JSON corruption during %s access', async (access) => { + it.each(['read', 'write'] as const)('isolates malformed child JSON during parent %s access', async (access) => { const f = await fixture() const childPath = await f.write('child', [], true) const first = JSON.stringify({ ...f.header, id: 'child', origin: 'subagent', parentSession: f.parent, delegationDepth: 1 }) + '\n' - // A sealed turn makes the malformed row corruption rather than a recoverable tail. const body = '{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }) + '\n' await writeFile(childPath, compression === 'none' ? first + body : Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)])) - const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) - const original = await Promise.all([readFile(parentPath), readFile(childPath)]) - const error: unknown = await f.ctx.sessionPersistence.open(f.parent, access).catch((error: unknown) => error) - expect(error).toBeInstanceOf(SessionPersistenceCorruptionError) - expect((error as Error).message).toContain(childPath) - expect((error as Error).message).not.toContain(parentPath) - expect((error as Error).message).toContain('row 1 is not valid JSON') - expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) - expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) + const original = await readFile(childPath) + await f.write('healthy', [f.descriptor], true) + const parent = await f.ctx.sessionPersistence.open(f.parent, access) + try { expect((await parent.read()).events).toMatchObject([ + { type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }, + { type: 'subagent/catalog', data: { childId: 'healthy', mode: 'continuable' } }, + ]) } finally { await parent.close() } + await expect(f.ctx.sessionPersistence.open(SessionId('child'), access)).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) + expect(await readFile(childPath)).toEqual(original) + await expect(readFile(generationLogPath(f.root, undefined, SessionId('child'), 4, compression))).rejects.toMatchObject({ code: 'ENOENT' }) }) it('preserves opaque inherited catalogs through read preparation and write publication', async () => { @@ -165,21 +187,20 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', expect(await readFile(parentPath)).toEqual(original) }) - it.skipIf(compression !== 'zstd')('locates invalid compressed child frames at the child path', async () => { + it.skipIf(compression !== 'zstd')('isolates invalid compressed child frames', async () => { const f = await fixture() const childPath = await f.write('child', [], true) await writeFile(childPath, Buffer.concat([await readFile(childPath), Buffer.alloc(8)])) - const error: unknown = await f.read().catch((error: unknown) => error) - expect(error).toBeInstanceOf(SessionPersistenceCorruptionError) - expect((error as Error).message).toContain(childPath) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) }) - it('keeps a complete parent catalog when a published child never writes a descriptor', async () => { + it.each([false, true])('keeps a complete parent catalog when child metadata is unavailable (corrupt=%s)', async (corrupt) => { const f = await fixture() const catalog = { type: 'subagent/catalog', seq: 0, time: 2, data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot', label: 'failed startup' } } const parentPath = await f.write(f.parent, [catalog]) - const childPath = await f.write('child', [], true) + const childPath = await f.write('child', corrupt ? [{ type: 'future/required', seq: 0, time: 2, data: {} }] : [], true) const original = await Promise.all([readFile(parentPath), readFile(childPath)]) expect(await f.read()).toEqual([catalog]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') @@ -208,10 +229,10 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const childPath = await f.write('child', events, true) const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) const original = await Promise.all([readFile(parentPath), readFile(childPath)]) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') - try { expect((await writer.read()).events).toEqual([]) } finally { await writer.close() } - expect(await f.read()).toEqual([]) + try { expect((await writer.read()).events).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) } finally { await writer.close() } + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read') try { expect((await child.read()).events).toEqual(events) } finally { await child.close() } expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) @@ -219,35 +240,37 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', .toEqual(compression === 'none' ? ['session.v3.jsonl', 'session.v4.jsonl'] : ['session.v3.jsonl.zstd', 'session.v4.jsonl.zstd']) }) - it.each([false, true])('refuses an opaque future generation before publishing membership (prepared=%s)', async (prepared) => { + it.each([false, true])('omits an unsupported child generation (prepared=%s)', async (prepared) => { const f = await fixture() await f.write('child', [f.descriptor], true) if (prepared) expect(await f.read()).toHaveLength(1) await f.write('child', [], true, 5) - await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) + expect(await f.read()).toEqual([]) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + await writer.close() + await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) }) - it('requires a readable corpus even when an unknown header cannot identify its parent', async () => { + it('opens the parent independently of an unrelated future generation', async () => { const f = await fixture() await f.write('future-root', [], false, 5) expect((await f.ctx.sessionPersistence.list()).map(row => row.header.id)).toEqual([f.parent]) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) + expect(await f.read()).toEqual([]) }) - it('preserves unsupported child decoding errors instead of reporting parent corruption', async () => { + it('retains unknown child identity without migrating unsupported contents', async () => { const f = await fixture() - const path = await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - await expect(f.read()).rejects.toThrow(path) + await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) }) - it('locates incomplete historical descriptor fields at their child source', async () => { + it('retains unknown child identity and reports invalid descriptor fields', async () => { const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn') const path = await f.write('child', [{ ...f.descriptor, data: { version: 2, provider: 'spawn', mode: 'unknown' } }], true) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - await expect(f.read()).rejects.toThrow(path) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(path)) }) it.each(['read', 'write'] as const)('rejects duplicate own catalogs through a current %s open', async (access) => { @@ -281,7 +304,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', it('recollects newly available discovery fields before publishing a prepared parent', async () => { const f = await fixture() const childPath = await f.write('child', [], true) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) await f.write('child', [f.descriptor], true) await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(childPath) const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) @@ -377,4 +400,48 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const reopened = await ctx.sessionPersistence.open(parent, 'read') try { expect((await reopened.read()).events).toEqual(expected) } finally { await reopened.close() } }) + + it('recollects a repaired child before publishing a prepared parent', async () => { + const f = await fixture() + await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + await f.write('child', [f.descriptor], true) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child' } }]) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + await writer.close() + }) + + it('keeps published unknown membership while a repaired child opens independently', async () => { + const f = await fixture() + await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + await writer.close() + const current = generationLogPath(f.root, undefined, f.parent, 4, compression) + const published = await readFile(current) + await f.write('child', [f.descriptor], true) + const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read') + try { expect((await child.read()).events).toEqual([f.descriptor]) } finally { await child.close() } + expect(await f.read()).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }]) + expect(await readFile(current)).toEqual(published) + }) + + it('completes only the opened parent catalog and defers its child catalog', async () => { + const f = await fixture() + const childPath = await f.write('child', [f.descriptor], true) + const grandchildPath = await f.write('grandchild', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) + // Reparent the physical fixture so only opening the child can inspect this damaged log. + const first = JSON.stringify({ ...f.header, id: 'grandchild', origin: 'subagent', parentSession: 'child', createdAt: 3, delegationDepth: 2 }) + '\n' + const body = JSON.stringify({ type: 'future/required', seq: 0, time: 3, data: {} }) + '\n' + await writeFile(grandchildPath, compression === 'none' ? first + body : Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)])) + const warn = vi.spyOn(f.ctx.logger, 'warn') + const source = await readFile(childPath) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + try { expect((await writer.read()).events).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child' } }]) } finally { await writer.close() } + expect(warn).not.toHaveBeenCalled() + expect(await readFile(childPath)).toEqual(source) + await expect(readFile(generationLogPath(f.root, undefined, SessionId('child'), 4, compression))).rejects.toMatchObject({ code: 'ENOENT' }) + const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'write') + await child.close() + expect(warn).toHaveBeenCalledWith(expect.stringContaining(grandchildPath)) + }) }) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 4ec279ac61..f7b2f98628 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -782,7 +782,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual([...oneTurnLog(), ...secondTurn]) }) - it('skips empty, incomplete, and non-header compressed artifacts while rejecting malformed header frames', async () => { + it('skips unreadable compressed headers during discovery while rejecting targeted reads', async () => { const root = await freshRoot() for (const [id, content] of [ ['empty', Buffer.alloc(0)], @@ -803,7 +803,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { JSON.stringify({ type: 'turn/start' }), '', ].join('\n'))) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/first frame is not exactly one header line/) + expect(await ctx.sessionPersistence.list()).toEqual([]) await expect(ctx.sessionPersistence.open(twoLinesId, 'read')) .rejects.toThrow(/first frame is not exactly one header line/) }) @@ -824,7 +824,11 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { .rejects.toThrow(/empty or header-less Zstandard session log/) await expect(ctx.sessionPersistence.open(SessionId('empty-header'), 'read')) .rejects.toThrow(/first frame is not exactly one header line/) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header frame failed validation/) + expect(await ctx.sessionPersistence.list()).toEqual([]) + await expect(ctx.sessionPersistence.stat(SessionId('bad-checksum'))) + .rejects.toThrow(/header frame failed validation/) + await expect(ctx.sessionPersistence.open(SessionId('bad-checksum'), 'read')) + .rejects.toThrow(/frame at byte 0 failed validation/) }) }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index f2d1e1d365..fb26e9f540 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 7d62e423189906c49d9479bd7a6dbd14b7303afd -README.zh.md: af62ad97bc4821222d46ed73509c823851953e1d +README.md: 4ed03104d3d8ab70816ee0cff7334a253196aef2 +README.zh.md: 92acf7498f282cdefea4f440d59e17f6dfffc81a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 7d62e42318..4ed03104d3 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -109,7 +109,7 @@ A request is validated against the provider's advertised capabilities, a durable The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream. -Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. +Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. Catalog payload v0 records known modes; v1 also accepts unknown mode. Readers support both versions. Historical migration appends a v1 `subagent/catalog` from a readable child header when its descriptor is unavailable; normal creation retains v0. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. ### Ownership and invariants diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index af62ad97bc..92acf7498f 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -109,7 +109,7 @@ kind: "package-reference" 管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才释放句柄。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。 -本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 +本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。Catalog 载荷 v0 记录已知模式,v1 还接受未知模式,读取器支持两版。历史迁移在 descriptor 不可用时根据可读子 header 追加 v1 `subagent/catalog`;正常创建保留 v0。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 ### 所有权与不变式 diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index 3ec29bbd2f..81edf47fb6 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -17,24 +17,27 @@ import type { import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { SubagentCatalogEntry } from './projection-types.ts' -/** Current payload version for `subagent/catalog` events. */ +/** Catalog payload version emitted by live child creation. */ export const SUBAGENT_CATALOG_VERSION = 0 -/** One complete parent-owned catalog fact. */ +type KnownCatalogMode = + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + +/** Parent catalog v0 records known modes; v1 also retains children with unknown mode. */ export type SubagentCatalogEvent = & { - readonly version: 0 readonly childId: SessionId readonly childCreatedAt: number } & ( - | { readonly mode: 'one-shot'; readonly label?: string } - | { readonly mode: 'continuable'; readonly label: string } + | ({ readonly version: 0 } & KnownCatalogMode) + | ({ readonly version: 1 } & (KnownCatalogMode | { readonly mode: 'unknown'; readonly label?: string })) ) declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent @@ -49,23 +52,25 @@ export interface SubagentCatalogState { const sessionIdSchema = z.string() as unknown as z.ZodType const oneShotCatalogSchema = z.object({ - version: z.literal(SUBAGENT_CATALOG_VERSION), + version: z.union([z.literal(0), z.literal(1)]), childId: sessionIdSchema, childCreatedAt: z.number().int().nonnegative(), mode: z.literal('one-shot'), label: z.string().optional(), }).strict() const continuableCatalogSchema = z.object({ - version: z.literal(SUBAGENT_CATALOG_VERSION), + version: z.union([z.literal(0), z.literal(1)]), childId: sessionIdSchema, childCreatedAt: z.number().int().nonnegative(), mode: z.literal('continuable'), label: z.string(), }).strict() +const unknownCatalogSchema = oneShotCatalogSchema.extend({ version: z.literal(1), mode: z.literal('unknown') }) const eventDataSchema = z.union([ oneShotCatalogSchema, continuableCatalogSchema, -]) as unknown as z.ZodType + unknownCatalogSchema, +]) as z.ZodType const viewSchema = z.array(z.union([ oneShotCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ id: sessionIdSchema, @@ -75,7 +80,11 @@ const viewSchema = z.array(z.union([ id: sessionIdSchema, createdAt: continuableCatalogSchema.shape.childCreatedAt, }), -])) as unknown as z.ZodType + unknownCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ + id: sessionIdSchema, + createdAt: unknownCatalogSchema.shape.childCreatedAt, + }), +])) as z.ZodType const stateSchema: z.ZodType = z.object({ inheritedEventCount: z.number().int().nonnegative() as unknown as z.ZodType, head: chunkedListSchema(eventDataSchema).optional(), @@ -88,14 +97,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } /** - * Materialize direct children from their parent's successful creation facts. + * Materialize complete and unknown-mode child identities from parent catalog events. * @param state - parent catalog fold state. * @returns current direct-child rows in parent catalog event order. */ function subagentCatalogEntries(state: SubagentCatalogState): SubagentCatalogEntry[] { const entries: SubagentCatalogEntry[] = [] for (const data of iterateChunkedList(state.head)) { - entries.push(data.mode === 'one-shot' + entries.push(data.mode !== 'continuable' ? { id: data.childId, createdAt: data.childCreatedAt, @@ -121,7 +130,7 @@ export const subagentCatalogProjectionDefinition = { if (event.type !== 'subagent/catalog' || event.seq < state.inheritedEventCount) return state return { ...state, head: appendChunkedList(state.head, eventDataSchema.parse(event.data)) } }, - stateVersion: 2, + stateVersion: 3, wire: { viewSchema, view: subagentCatalogEntries }, } satisfies ProjectionDefinition<'subagentCatalog', SubagentCatalogState> diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index 10a8cfc426..965f4d70dc 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -71,7 +71,7 @@ export type SubagentListEntry = readonly reason: 'corrupt' | 'unsupported' | 'unavailable' } -/** Durable parent/child address that selects subagent transport in the client. */ +/** Durable parent/child browsing address; unknown mode is resolved when child history is read. */ export type SubagentAddress = & { readonly parentSessionId: SessionId @@ -80,6 +80,7 @@ export type SubagentAddress = & ( | { readonly mode: 'one-shot' } | { readonly mode: 'continuable' } + | { readonly mode: 'unknown' } ) /** One human message addressed to a continuable direct child. */ diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index c33a2df9fb..27201624fb 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -15,6 +15,7 @@ export type SubagentCatalogEntry = & ( | { readonly mode: 'one-shot'; readonly label?: string } | { readonly mode: 'continuable'; readonly label: string } + | { readonly mode: 'unknown'; readonly label?: string } ) /** Durable active-turn timing for one descriptor-backed child session. */ diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index cfd51813fe..ffc95e1606 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -102,11 +102,26 @@ describe('subagent catalog projection', () => { .toEqual(Array.from({ length: 130 }, (_, i) => SessionId(`child-${i}`))) }) + it.each([0, 1] as const)('reads known catalog modes at payload version %s', (version) => { + const events: SessionEvent[] = [ + { type: 'subagent/catalog', seq: SessionSeq(0), time: 0, + data: { version, childId: SessionId('once'), childCreatedAt: 1, mode: 'one-shot' } }, + { type: 'subagent/catalog', seq: SessionSeq(1), time: 0, + data: { version, childId: SessionId('ongoing'), childCreatedAt: 2, mode: 'continuable', label: 'child' } }, + ] + expect(subagentCatalogProjectionDefinition.wire.view(fold(events))).toEqual([ + { id: SessionId('once'), createdAt: 1, mode: 'one-shot' }, + { id: SessionId('ongoing'), createdAt: 2, mode: 'continuable', label: 'child' }, + ]) + }) + it('validates recursive chunk state and materializes every label variant in event order', () => { const events: SessionEvent[] = [ fact(0, 'child-b', 1, { mode: 'one-shot' }), fact(1, 'child-a', 1, { mode: 'one-shot', label: 'once' }), fact(2, 'child-d', 3, { mode: 'continuable', label: 'later' }), + { type: 'subagent/catalog', seq: SessionSeq(3), time: 0, + data: { version: 1, childId: SessionId('unreadable'), childCreatedAt: 4, mode: 'unknown' } }, ] const state = fold(events) @@ -121,6 +136,7 @@ describe('subagent catalog projection', () => { { id: SessionId('child-b'), createdAt: 1, mode: 'one-shot' }, { id: SessionId('child-a'), createdAt: 1, mode: 'one-shot', label: 'once' }, { id: SessionId('child-d'), createdAt: 3, mode: 'continuable', label: 'later' }, + { id: SessionId('unreadable'), createdAt: 4, mode: 'unknown' }, ]) }) @@ -140,6 +156,7 @@ describe('subagent catalog projection', () => { }) it.each([ + { version: 0, childId: 'child', childCreatedAt: 0, mode: 'unknown' }, { version: 9, childId: 'child', childCreatedAt: 0, mode: 'one-shot' }, { version: 0, childId: 'child', childCreatedAt: 0, mode: 'continuable' }, { version: 0, childId: 'child', childCreatedAt: -1, mode: 'one-shot' }, diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index d37d28f763..6a6ad363c6 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md -README.md: 1e9a9aeabe8b27f67557216f354d31a6ccf9d594 -README.zh.md: 27f3f5447f2845f5dbcbc928d62222cb0fbefe84 +README.md: b4197ffc750ec585e4df1b9da9af1731558bfc99 +README.zh.md: 087d464ba900965d23b09d1ff9f67b9259f08933 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 1e9a9aeabe..b4197ffc75 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -89,7 +89,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every ### Roster and startup behavior -`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 27f3f5447f..087d464ba9 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -89,7 +89,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### Roster 与启动行为 -`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 diff --git a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts index 14284556f5..bd945ab43f 100644 --- a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts +++ b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts @@ -17,7 +17,7 @@ import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { evaluate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { exactPackageSpecifier, parseDshClient } from '@deepseek-ai/dsh-client-modules/client' import * as yaml from 'js-yaml' @@ -41,11 +41,16 @@ interface BundleLayer { * Compose the browser roster of `bundles`, applied in order. * @param bundles - bundle package names in application order. * @param anchor - file whose package resolution locates the bundles; default this package. + * @param disabledContext - optional Loader evaluation scope for trusted `disabled` expressions. * @returns the roster in composition order, one row per package. * @throws {Error} when a bundle, its patch file, or an enabled row's package does not resolve, when the patch list - * is not a list or does not apply as written, or when a browser row's `disabled` is a `!!js` expression. + * is not a list or does not apply as written, or when a browser row has an unevaluated `disabled` expression. */ -export function bundleRoster(bundles: readonly string[], anchor: string = fileURLToPath(import.meta.url)): ClientRoster { +export function bundleRoster( + bundles: readonly string[], + anchor: string = fileURLToPath(import.meta.url), + disabledContext?: object, +): ClientRoster { const layers = bundles.map(name => readLayer(name, anchor)) const entries = applyEntryPatches([], layers.flatMap(layer => layer.patches), (message: string, ...args: unknown[]) => { throw new Error(`client-test-runtime: bundle patch ${describe(message, args)}`) @@ -55,7 +60,9 @@ export function bundleRoster(bundles: readonly string[], anchor: string = fileUR const seen = new Set() for (const { entry, disabled } of flattenGroups(entries)) { const name = exactPackageSpecifier(entry.name) - if (name === undefined || disabled === true || seen.has(name)) continue + if (name === undefined || disabled.includes(true) || seen.has(name)) continue + if (disabledContext !== undefined + && disabled.some(value => isJsExpr(value) && Boolean(evaluate(disabledContext, value.__jsExpr)))) continue seen.add(name) const manifestPath = locateManifest(anchors, name) if (manifestPath === undefined) { @@ -67,7 +74,8 @@ export function bundleRoster(bundles: readonly string[], anchor: string = fileUR } const declaration = parseDshClient(name, manifest.dsh?.client) if (declaration === undefined || declaration.platform !== 'web') continue - if (disabled !== undefined && disabled !== null && typeof disabled !== 'boolean') { + if (disabled.some(value => value !== undefined && value !== null && typeof value !== 'boolean' + && !(disabledContext !== undefined && isJsExpr(value)))) { throw new Error(`client-test-runtime: browser row ${name} has a \`disabled\` value this reader cannot evaluate (a !!js expression)`) } rows.push({ name, inject: declaration.inject ?? [], immediately: declaration.immediately === true }) @@ -86,24 +94,23 @@ function readLayer(bundle: string, anchor: string): BundleLayer { return { manifestPath, patches: parsed as PatchOptions[] } } -/** One Loader row with the `disabled` value that governs it: its own, or the nearest enclosing group's when that is set. */ +/** One Loader row with its ancestor and own disable conditions, in outer-to-inner order. */ interface FlatEntry { readonly entry: EntryOptions - readonly disabled: unknown + readonly disabled: readonly unknown[] } /** * Rows in Loader order with groups descended, as the Loader loads them: a group is never a plugin itself, and a group's * `disabled` disables every row beneath it. * @param entries - composed entries, possibly nested. - * @param inherited - the enclosing group's `disabled` when set. + * @param inherited - the enclosing groups' disable conditions. * @returns the plugin rows. */ -function flattenGroups(entries: readonly EntryOptions[], inherited?: unknown): FlatEntry[] { +function flattenGroups(entries: readonly EntryOptions[], inherited: readonly unknown[] = []): FlatEntry[] { const rows: FlatEntry[] = [] for (const entry of entries) { - const own = (entry as { disabled?: unknown }).disabled - const disabled = inherited !== undefined && inherited !== null && inherited !== false ? inherited : own + const disabled = [...inherited, (entry as { disabled?: unknown }).disabled] if (entry.group === true && Array.isArray(entry.config)) { rows.push(...flattenGroups(entry.config as EntryOptions[], disabled)) continue @@ -134,5 +141,9 @@ function describe(message: string, args: readonly unknown[]): string { return message.replace(/%C/g, () => JSON.stringify(args[index++])) } -/** The `web` profile's browser roster, composed from its bundles at import. */ -export const webApp: ClientRoster = bundleRoster(WEB_PROFILE_BUNDLES) +const webProfileServices: Readonly> = { profileContext: { name: 'web' } } + +/** The `web` profile's browser roster, composed with its profile name and no business Host services. */ +export const webApp: ClientRoster = bundleRoster(WEB_PROFILE_BUNDLES, undefined, { + get: (name: string) => webProfileServices[name], +}) diff --git a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts index e73ce00818..a1fa3f6191 100644 --- a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts @@ -7,6 +7,11 @@ import { afterAll, describe, expect, it } from 'vitest' import { MODULES_PACKAGE } from '../src/assembly/modules.ts' import { WEB_PROFILE_BUNDLES, bundleRoster, webApp } from '../src/assembly/bundle-roster.ts' +function profileScope(name: string) { + const services: Record = { profileContext: { name } } + return { get: (key: string) => services[key] } +} + describe('webApp (the real web profile)', () => { it('composes dsh-base then dsh-web-app: unique names, inject edges on roster rows or platform seed words', () => { expect(WEB_PROFILE_BUNDLES).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) @@ -15,7 +20,10 @@ describe('webApp (the real web profile)', () => { const known = new Set([...names, ...Object.keys(getStaticModules())]) const dangling = webApp.rows.flatMap(row => row.inject.filter(target => !known.has(target)).map(target => `${row.name} -> ${target}`)) expect(dangling).toEqual([]) - expect(bundleRoster(WEB_PROFILE_BUNDLES).rows).toEqual(webApp.rows) + expect(bundleRoster(WEB_PROFILE_BUNDLES, undefined, profileScope('web')).rows).toEqual(webApp.rows) + expect(names).not.toContain('@deepseek-ai/dsh-client-ui-sidebar-browser') + expect(bundleRoster(WEB_PROFILE_BUNDLES, undefined, profileScope('desktop')).rows.map(row => row.name)) + .toContain('@deepseek-ai/dsh-client-ui-sidebar-browser') }) it('keeps browser rows with their declarations and drops Host-only, disabled, and subpath rows', () => { @@ -154,6 +162,84 @@ describe('bundleRoster on a scratch installation', () => { expect(() => scratch.roster(['@t/maybe-bundle'])).toThrow('browser row @t/maybe has a `disabled` value this reader cannot evaluate') }) + it('evaluates profile conditions before deduplication and skips ignored row expressions', () => { + scratch.web('@t/conditional') + scratch.bundle('@t/conditional-bundle', ` +- insert: + - id: ignored + name: ./ignored.mjs + disabled: !!js missingIgnoredValue + - id: first + name: '@t/conditional' + disabled: !!js ctx.get('profileContext').name !== 'desktop' + - id: second + name: '@t/conditional' + disabled: false +`) + expect(bundleRoster(['@t/conditional-bundle'], scratch.anchor, profileScope('web')).rows.map(row => row.name)) + .toEqual(['@t/conditional']) + }) + + it('combines ancestor and child conditions without evaluating disabled subtrees', () => { + scratch.web('@t/conditional-on') + scratch.web('@t/conditional-off') + scratch.bundle('@t/conditional-groups', ` +- insert: + - id: parent-off + name: cordis:group + group: true + disabled: !!js true + config: + - id: unreachable + name: '@t/not-installed' + disabled: !!js missingInactiveValue + - id: literal-off + name: cordis:group + group: true + disabled: true + config: + - id: also-unreachable + name: '@t/not-installed-either' + disabled: !!js missingInactiveValue + - id: parent-on + name: cordis:group + group: true + disabled: !!js false + config: + - id: child-off + name: '@t/conditional-off' + disabled: true + - id: nested-on + name: cordis:group + group: true + disabled: !!js false + config: + - id: child-on + name: '@t/conditional-on' + disabled: null +`) + expect(bundleRoster(['@t/conditional-groups'], scratch.anchor, {}).rows.map(row => row.name)) + .toEqual(['@t/conditional-on']) + }) + + it('propagates evaluation failures and rejects unsupported literal flags with a scope', () => { + scratch.web('@t/bad-condition') + scratch.bundle('@t/bad-expression', ` +- insert: + - id: bad + name: '@t/bad-condition' + disabled: !!js missingConditionValue +`) + expect(() => bundleRoster(['@t/bad-expression'], scratch.anchor, {})).toThrow('missingConditionValue') + scratch.bundle('@t/bad-literal', ` +- insert: + - id: bad + name: '@t/bad-condition' + disabled: not-a-boolean +`) + expect(() => bundleRoster(['@t/bad-literal'], scratch.anchor, {})).toThrow('disabled') + }) + it('fails loud on a bundle that does not resolve, declares no patch, or whose patch is not a list', () => { expect(() => scratch.roster(['@t/missing'])).toThrow('cannot resolve bundle @t/missing from') scratch.pkg('@t/no-patch', { dsh: {} }) diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index dee2ad6293..6aa6fb0107 100644 --- a/packages/util/package-manifest/README.i18n.yaml +++ b/packages/util/package-manifest/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/util/package-manifest/README.md -README.md: 86121432ba12329e111f2c796679ae019e403d7e -README.zh.md: d4d9d8b5b7787ac9531918fb1f7084c7c8a7a8d1 +README.md: 95dbbb729792872a9d41716db4fb4f2e8ac763b5 +README.zh.md: 02c21647f085af09472cca9f8c2e1a06be4e1124 diff --git a/packages/util/package-manifest/README.md b/packages/util/package-manifest/README.md index 86121432ba..95dbbb7297 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -52,7 +52,7 @@ The following metadata fields are optional. Omitting them leaves the format vers | `dsh.manifestVersion` | Manifest format identifier; the declared format is `1`, independent of the npm package version and Session format version. | | `engines.dsh` | Author-declared compatible DSH versions as a SemVer range, including exact prerelease versions. This field sits beside `engines.node` and `engines.npm`; an engines object may omit `dsh`. | -`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional display title and description fields, or a metadata diagnostic, for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types. +`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional display title, description, an image data URL resolved from `package.json.icon`, and metadata diagnostics for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types. Public composition declarations are defined in [`src/types.ts`](src/types.ts). Internal `configTrees`, `sessionFormatMigration`, and generated `moduleFallback` metadata remain owned by their image-packer, catalog, and launcher readers; the public types do not expose them. diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index d4d9d8b5b7..02c21647f0 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -52,7 +52,7 @@ const manifest: DshPackageManifest = { | `dsh.manifestVersion` | manifest(元数据清单)格式标识;声明的格式为 `1`,独立于 npm 包版本和 Session 格式版本。 | | `engines.dsh` | 作者声明的兼容 DSH 版本,使用 SemVer 范围,也可填写精确的预发布版本。此字段与 `engines.node`、`engines.npm` 并列;engines 对象可省略 `dsh`。 | -`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带已安装插件的可选展示标题与描述字段,或元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。 +`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带已安装插件的可选展示标题、描述、从 `package.json.icon` 解析的图片 data URL,以及元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。 公共组合声明定义在 [`src/types.ts`](src/types.ts) 中。内部 `configTrees`、`sessionFormatMigration` 和生成的 `moduleFallback` 元数据分别由镜像打包器、目录生成器和启动器读取方拥有;公共类型不暴露这些字段。 diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index d6f161f97e..a028c71b02 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -12,6 +12,8 @@ export interface DshPackageManifest { version: string /** Package summary for discovery and display. */ description?: string + /** SVG, PNG, JPEG, or WebP file relative to this manifest's directory, at most 256 KiB and contained there after realpath resolution. */ + icon?: string /** Prevent npm publication, for example for local profile projects. */ private?: boolean /** Packages installed alongside this package. */ @@ -39,12 +41,14 @@ export interface DshManifest { /** Literal text or translations indexed by lowercase language id, with a required English fallback. */ export type LocalizedText = string | { readonly en: string; readonly [locale: string]: string } -/** Validated plugin display fields or a diagnostic from reading exported locale files. */ +/** Validated plugin display fields and diagnostics from exported locales, manifests, or icon files. */ export interface PluginLocalizedMeta { /** Display title; omission preserves the consumer's technical-name fallback. */ readonly title?: LocalizedText /** Display introduction after locale and package-field fallback. */ readonly description?: LocalizedText + /** Base64 image data URL read from the manifest's icon file; render as an image, not inline markup. */ + readonly icon?: string /** Unmodified local metadata diagnostic; the plugin remains manageable. */ readonly error?: string } diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 0d1cc64097..66b9b2665c 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -595,6 +595,15 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None: client = HarnessClient() with client.subscribe_session_notifications("main") as first: + unknown_child = { + "type": "subagent/catalog", "seq": 0, "time": 1, + "data": {"version": 1, "childId": "unreadable-child", "childCreatedAt": 1, "mode": "unknown"}, + } + client._handle_message({ + "jsonrpc": "2.0", "method": "session.event", + "params": {"sessionId": "main", "event": unknown_child}, + }) + assert first.next().payload == {"sessionId": "main", "event": unknown_child} client._handle_message({ "jsonrpc": "2.0", "method": "subagent.started", diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 0a06641c14..7d012f6440 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -164,6 +164,20 @@ describe('dsh family version coherence', () => { }) describe('package payload constraints', () => { + it.each(['./art/icon.svg', 'art/icon.svg'])('includes declared icon %s in the canonical payload', (icon) => { + expect(expectedDshPackageFiles({ icon, exports: { './locale/*.json': './locale/*.json' } })).toEqual([ + 'art/icon.svg', 'locale/*.json', 'lib/index.js', 'lib/types/**/*.d.ts', + ]) + }) + + it('accepts the Agent Team icon payload and rejects its omission', () => { + const dir = 'packages/experimental/agent-team-profile' + const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] + expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) + expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: manifest.files!.filter(file => file !== 'icon.svg') } })) + .toEqual([expect.stringContaining('package.json files must be')]) + }) + it.each([ { exports: { './locale/*.json': './locale/*.json' }, resources: ['locale/*.json'] }, { exports: { './search/locale/*.json': './resources/search/*.json' }, resources: ['resources/search/*.json'] }, diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 2372546421..aeb4415b97 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -90,6 +90,7 @@ export interface PackageManifest { | undefined > files?: string[] + icon?: string publishConfig?: { access?: string } repository?: { type?: string; url?: string; directory?: string } peerDependencies?: Record @@ -210,9 +211,9 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl } /** - * Compute canonical publication patterns, including explicitly exported locale JSON resources. + * Compute canonical publication patterns, including the declared icon and exported locale JSON resources. * @param manifest - workspace package manifest. - * @returns deduplicated locale targets followed by the package's runtime and declaration payloads. + * @returns the icon and deduplicated locale targets followed by runtime and declaration payloads. */ export function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const localeFiles = new Set() @@ -228,6 +229,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str ...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []), ] return [ + ...typeof manifest.icon === 'string' ? [manifest.icon.replace(/^\.\//u, '')] : [], ...[...localeFiles].sort(), 'lib/index.js', // Packages with an invariant export publish its runtime as a separate diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 76dbf82860..df1274c57c 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -1,15 +1,112 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import type { TsdownBundle } from 'tsdown' import { writeClientBuildRecord } from './client-build-environment.ts' import { devWebBuildEnvironment, discoverLibraryDirs, discoverPluginDirs, + parseDevWebArguments, + StageSupervisor, watchClientPlugins, } from './dev-web.ts' +import type { StageHandle } from './dev-web.ts' + +/** A stage double that exits only on the listed signals or an explicit exit code. */ +function fakeStage(name: string, exitOn: readonly NodeJS.Signals[]): { + handle: StageHandle + signals: NodeJS.Signals[] + exit: (code: number) => void +} { + const exit = Promise.withResolvers() + const signals: NodeJS.Signals[] = [] + return { + handle: { + name, + exited: exit.promise, + kill(signal) { + signals.push(signal) + if (exitOn.includes(signal)) exit.resolve(null) + }, + }, + signals, + exit: (code) => { exit.resolve(code) }, + } +} + +describe('StageSupervisor', () => { + it('reports a stage that exits on its own', async () => { + const stale: [string, number | null][] = [] + const supervisor = new StageSupervisor((name, code) => { stale.push([name, code]) }) + const tsc = fakeStage('tsc', ['SIGTERM']) + supervisor.add(tsc.handle) + tsc.exit(2) + await tsc.handle.exited + expect(stale).toEqual([['tsc', 2]]) + }) + + it('forwards one signal, escalates survivors after the grace period, and reports none of them', async () => { + const stale: string[] = [] + const supervisor = new StageSupervisor((name) => { stale.push(name) }) + const polite = fakeStage('vite', ['SIGTERM']) + const stubborn = fakeStage('dsh web', ['SIGKILL']) + supervisor.add(polite.handle) + supervisor.add(stubborn.handle) + await supervisor.stop({ signal: 'SIGTERM', graceMs: 20 }) + expect(polite.signals).toEqual(['SIGTERM']) + expect(stubborn.signals).toEqual(['SIGTERM', 'SIGKILL']) + expect(stale).toEqual([]) + }) + + it('waits for stages the terminal already interrupted before signaling survivors', async () => { + const supervisor = new StageSupervisor(() => { throw new Error('unexpected stale report') }) + const interrupted = fakeStage('tsdown', []) + const survivor = fakeStage('dsh web', ['SIGTERM']) + supervisor.add(interrupted.handle) + supervisor.add(survivor.handle) + const stopping = supervisor.stop({ graceMs: 20 }) + interrupted.exit(130) + await stopping + expect(interrupted.signals).toEqual([]) + expect(survivor.signals).toEqual(['SIGTERM']) + }) +}) + +describe('parseDevWebArguments', () => { + it('builds, serves, and watches natively by default', () => { + expect(parseDevWebArguments([])).toEqual({ skipBuild: false, serve: true, pollInterval: undefined, appArgs: [] }) + }) + + it('reads the polling interval with its 500ms default', () => { + expect(parseDevWebArguments(['--poll']).pollInterval).toBe(500) + expect(parseDevWebArguments(['--poll=250']).pollInterval).toBe(250) + }) + + it.each(['--poll=abc', '--poll=0', '--poll=-5', '--poll=1.5'])('rejects the polling interval %s', (flag) => { + expect(() => parseDevWebArguments([flag])).toThrow(`invalid --poll interval "${flag}"`) + }) + + it('separates its own flags from the arguments forwarded to dsh web', () => { + expect(parseDevWebArguments(['--skip-build', '--poll', '--no-open', '--port', '8080'])).toEqual({ + skipBuild: true, serve: true, pollInterval: 500, appArgs: ['--no-open', '--port', '8080'], + }) + }) + + it('runs only the rebuild watchers with --no-serve', () => { + expect(parseDevWebArguments(['--no-serve', '--skip-build'])).toMatchObject({ serve: false, skipBuild: true }) + }) + + it('ignores the separator pnpm run forwards verbatim', () => { + expect(parseDevWebArguments(['--', '--no-open']).appArgs).toEqual(['--no-open']) + expect(parseDevWebArguments(['--no-serve', '--'])).toMatchObject({ serve: false, appArgs: [] }) + }) + + it('rejects dsh web arguments when no server is started', () => { + expect(() => parseDevWebArguments(['--no-serve', '--no-open'])).toThrow('--no-serve leaves no dsh web process for --no-open') + }) +}) it('samples one local environment at startup without validating watcher outputs', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-')) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 35500eca13..dd282d773c 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -1,28 +1,38 @@ /** - * Watch-build for the web dev loop: rebuilds every artifact the browser reads - * from a source edit. Reload signaling is not this script's business — the host - * webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames - * itself (`dsh web`), so any process that rewrites `lib/client.js` files - * triggers reloads; this script is merely the convenient way to keep them all - * rebuilt on source change. + * The Web development loop: build once, serve through `dsh web`, and keep every + * browser-side artifact rebuilt on source edits, in one terminal. * - * Three stages, because the compile shell links built lib products rather than - * sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib - * entries are that emit, not `src`), tsdown bundles `lib/index.js` and - * `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web` - * serves. A missing stage does not fail — it silently shows the previous - * artifact, so an edit appears to do nothing. + * Stages, in order: `pnpm run build` (skipped by `--skip-build`), three + * long-lived watchers, then `dsh web` (skipped by `--no-serve`). The watchers + * exist because the compile shell links built lib products rather than + * sources: `tsc -b tsconfig.client.json --watch` emits `lib/types` (the tsdown + * lib entries are that emit, not `src`), tsdown watch bundles `lib/index.js` + * and `lib/client.js`, and `vite build --watch` rewrites `apps/web/dist`, which + * `dsh web` serves. A missing watcher does not fail — it silently shows the + * previous artifact, so an edit appears to do nothing; any stage exiting on its + * own therefore stops the loop with exit code 1. * - * MUST NOT run concurrently with `pnpm run build`: both write the same - * `lib/` and `apps/web/dist/` trees. + * Reload signaling is not this script's business — the host webserver + * stat-polls the bundles it serves and broadcasts `rebuilt` frames itself, so + * any process that rewrites `lib/client.js` files triggers reloads. * - * Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior - * `pnpm run build`: every stage is incremental over the previous stage's output - * and none of them bootstraps a missing tree. `--poll` switches the source - * watchers to polling (default 500ms): network mounts (weka) deliver no inotify - * events, so native watching sees the initial build only and never a source - * change. Polling has to reach tsc too — a native-watching tsc never re-emits - * `lib/types`, which strands the other two stages on stale input. + * MUST NOT run beside `pnpm run build`: both write the same `lib/` and + * `apps/web/dist/` trees. The build stage here finishes before any watcher starts. + * + * Usage: `pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]`. + * `--skip-build` requires the artifact tree from a prior complete build: every + * watcher is incremental over the previous stage's output and none of them + * bootstraps a missing tree. `--no-serve` keeps only the watchers, for a + * `dsh web` started elsewhere. `--poll` switches the source watchers to polling + * (default 500ms): network mounts (weka) deliver no inotify events, so native + * watching sees the initial build only and never a source change. Polling has + * to reach tsc too — a native-watching tsc never re-emits `lib/types`, which + * strands the other two stages on stale input. + * + * Shutdown: Ctrl+C reaches every stage through the terminal's process group, so + * the script only waits for them and escalates survivors; SIGTERM is addressed + * to this process alone and is forwarded once. The exit code is 130 after + * SIGINT, 0 after SIGTERM, and 1 when a stage exited on its own. * * Each package keeps its own tsdown.config.ts untouched: this script layers * `watch` through API-level inline config (tsdown workspace mode fills inline @@ -39,6 +49,7 @@ import { clientBuildProcessEnvironment, repositoryClientBuildEnvironment, } from './client-build-environment.ts' +import { pnpmInvocation } from './pnpm-invocation.ts' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -67,6 +78,51 @@ export function devWebBuildEnvironment( return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment)) } +/** Resolved `dev-web` command line: the script's own flags plus the arguments forwarded to `dsh web`. */ +export interface DevWebArguments { + /** Skip the complete `pnpm run build` that otherwise precedes the watchers. */ + readonly skipBuild: boolean + /** Start `dsh web`; false keeps only the rebuild watchers beside an already running server. */ + readonly serve: boolean + /** Source-watcher polling interval in milliseconds; undefined selects native watching. */ + readonly pollInterval: number | undefined + /** Arguments forwarded verbatim to `dsh web`, in order. */ + readonly appArgs: readonly string[] +} + +/** Polling interval selected by a bare `--poll`. */ +const DEFAULT_POLL_INTERVAL = 500 + +/** + * Parse the script's command line. `--skip-build`, `--no-serve`, and `--poll[=ms]` + * belong to this script; a bare `--` is dropped; every other token is forwarded + * to `dsh web`. + * @param argv - arguments after the script path. + * @returns the resolved flags and forwarded arguments. + * @throws Error when `--poll` carries a non-positive or non-integer interval, or + * when `--no-serve` leaves forwarded arguments without a `dsh web` process. + */ +export function parseDevWebArguments(argv: readonly string[]): DevWebArguments { + let skipBuild = false + let serve = true + let pollInterval: number | undefined + const appArgs: string[] = [] + for (const arg of argv) { + // `pnpm run` forwards a `--` separator verbatim; it carries no meaning here. + if (arg === '--') continue + if (arg === '--skip-build') skipBuild = true + else if (arg === '--no-serve') serve = false + else if (arg === '--poll' || arg.startsWith('--poll=')) { + pollInterval = arg === '--poll' ? DEFAULT_POLL_INTERVAL : Number(arg.slice('--poll='.length)) + if (!Number.isInteger(pollInterval) || pollInterval <= 0) throw new Error(`dev-web: invalid --poll interval "${arg}"`) + } else appArgs.push(arg) + } + if (!serve && appArgs.length > 0) { + throw new Error(`dev-web: --no-serve leaves no dsh web process for ${appArgs[0] ?? ''}`) + } + return { skipBuild, serve, pollInterval, appArgs } +} + /** * Discover the watch workspace by declaration: every packages// * whose package.json carries `dsh.client` with platform "web" is a client @@ -153,46 +209,166 @@ export async function watchClientPlugins( return bundles } -/** - * Live watcher processes to terminate when this script is interrupted. Stages - * register themselves as they start, so the set is complete from the first - * spawn: an interrupt during a later stage's startup still tears down the - * earlier ones instead of orphaning them. - */ -const stages: StageHandle[] = [] +/** One long-lived stage process as the supervisor observes it. */ +export interface StageHandle { + /** Command label used in diagnostics. */ + readonly name: string + /** Settles with the exit code, or null when a signal ended the stage. */ + readonly exited: Promise + /** + * Deliver one signal; a no-op once the stage has exited. + * @param signal - the signal to send. + */ + kill(signal: NodeJS.Signals): void +} + +/** How a requested stop reaches the stages. */ +export interface StageStop { + /** Signal sent to every live stage first; omitted when the terminal already delivered SIGINT to them. */ + readonly signal?: NodeJS.Signals | undefined + /** Milliseconds to wait for exits before escalating to SIGTERM, and again before SIGKILL. */ + readonly graceMs: number +} /** - * Spawn one watcher stage, inheriting stdio, registering it for teardown, and - * failing loud if it ever exits: a dead stage leaves the artifact chain silently - * stale, which reads as "my edit did nothing" — the one failure this script - * exists to prevent. - * @param stage - command label used in the exit diagnostic. - * @param command - executable, resolved from the workspace bin when local. + * Own the live stages: report a stage that exits before a stop was requested, + * and on a requested stop signal, wait, and escalate until every stage has exited. + */ +export class StageSupervisor { + private readonly live = new Set() + private stopping = false + + /** + * @param onStale - receives the stage name and exit code when a stage exits before a stop was requested. + */ + constructor(private readonly onStale: (stage: string, code: number | null) => void) {} + + /** + * Track one started stage until it exits. + * @param stage - the stage to supervise. + */ + add(stage: StageHandle): void { + this.live.add(stage) + void stage.exited.then((code) => { + this.live.delete(stage) + if (!this.stopping) this.onStale(stage.name, code) + }) + } + + /** + * Stop every live stage and resolve once all of them have exited. + * @param stop - initial signal and the grace period between escalation steps. + */ + async stop(stop: StageStop): Promise { + this.stopping = true + const escalation: (NodeJS.Signals | undefined)[] = [stop.signal] + if (stop.signal !== 'SIGTERM') escalation.push('SIGTERM') + for (const signal of escalation) { + if (this.live.size === 0) return + if (signal !== undefined) for (const stage of this.live) stage.kill(signal) + await this.settled(stop.graceMs) + } + for (const stage of this.live) stage.kill('SIGKILL') + await Promise.all([...this.live].map(stage => stage.exited)) + } + + private async settled(graceMs: number): Promise { + let timer: NodeJS.Timeout | undefined + await Promise.race([ + Promise.all([...this.live].map(stage => stage.exited)), + new Promise((resolve) => { timer = setTimeout(resolve, graceMs) }), + ]) + clearTimeout(timer) + } +} + +/** + * Grace period per escalation step. `dsh web` gives its plugin tree five seconds + * to dispose after the first signal; escalating earlier would turn the graceful + * drain into a forced exit. + */ +const STOP_GRACE_MS = 6_000 + +/** + * Spawn one long-lived stage with inherited stdio and hand it to the supervisor. + * @param supervisor - owner that reports an unexpected exit and stops the stage on shutdown. + * @param name - command label used in diagnostics. + * @param command - executable, resolved from the workspace bins when `local` is set. * @param args - command arguments. * @param local - whether to resolve `command` from the workspace's installed bins. */ -function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void { +function spawnStage( + supervisor: StageSupervisor, + name: string, + command: string, + args: readonly string[], + local: boolean, +): void { const child = execa(command, [...args], { cwd: repoRoot, stdio: 'inherit', preferLocal: local, reject: false, }) - stages.push({ kill: () => { child.kill() } }) - void child.then((result) => { - console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`) - process.exit(1) + supervisor.add({ + name, + exited: child.then(result => result.exitCode ?? null), + kill(signal) { child.kill(signal) }, }) } -/** The only capability this script needs from a live watcher process. */ -interface StageHandle { - readonly kill: () => void +/** + * Run the complete repository build with the launch environment untouched: + * `scripts/build.ts` samples the public client values itself. + * @returns the build's exit code, or null when a signal ended it. + */ +async function runBuild(): Promise { + const invocation = pnpmInvocation(['run', 'build']) + const result = await execa(invocation.command, invocation.args, { cwd: repoRoot, stdio: 'inherit', reject: false }) + return result.exitCode ?? null } const invokedPath = process.argv[1] const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href if (isMain) { + let options: DevWebArguments + try { + options = parseDevWebArguments(process.argv.slice(2)) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + console.error('dev-web: usage: pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]') + process.exit(1) + } + + // Shutdown is requested once, by a terminal signal or by a stage exiting on + // its own; `signal` stays unset for SIGINT because the terminal's process + // group already delivered it to every stage. + const shutdown = Promise.withResolvers() + const requested: { code?: number; signal?: NodeJS.Signals } = {} + const shutdownRequested = (): boolean => requested.code !== undefined + const requestShutdown = (code: number, signal: NodeJS.Signals | undefined): void => { + if (shutdownRequested()) return + requested.code = code + if (signal !== undefined) requested.signal = signal + shutdown.resolve(code) + } + const supervisor = new StageSupervisor((stage, code) => { + console.error(`dev-web: ${stage} exited (code ${String(code)}); stopping the other stages`) + requestShutdown(1, 'SIGTERM') + }) + // Persistent listeners, not `once`: the tsdown watchers bundle signal-exit, + // which re-raises a signal whenever it finds no other listener left and would + // terminate this process in the middle of the teardown below. + process.on('SIGINT', () => { requestShutdown(130, undefined) }) + process.on('SIGTERM', () => { requestShutdown(0, 'SIGTERM') }) + + const buildExit = options.skipBuild ? 0 : await runBuild() + if (shutdownRequested()) process.exit(await shutdown.promise) + if (buildExit !== 0) { + console.error(`dev-web: pnpm run build exited (code ${String(buildExit)})`) + process.exit(1) + } + const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env) for (const name of Object.keys(process.env)) { if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) { @@ -214,32 +390,14 @@ if (isMain) { process.exit(1) } - const args = process.argv.slice(2) - const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll=')) - if (args.some(a => a !== pollArg)) { - console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]') - process.exit(1) - } - const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500') - if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) { - console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`) - process.exit(1) - } - - // Registered before any stage starts: `stages` is read at signal time, so an - // interrupt during tsdown's initial builds still kills whatever is running. - const stop = (): void => { for (const stage of stages) stage.kill() } - process.once('SIGINT', stop) - process.once('SIGTERM', stop) - // tsc has no polling interval flag, so `--poll` selects its fixed-interval // watchers rather than an interval. Dropping that translation leaves tsc // natively watching on a network mount where inotify never fires: it stops // re-emitting lib/types, and the two later stages then rebuild forever from // stale input without printing anything. - spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [ + spawnStage(supervisor, `tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [ '-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput', - ...pollInterval !== undefined + ...options.pollInterval !== undefined ? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval'] : [], ], true) @@ -248,19 +406,34 @@ if (isMain) { // first build reads current lib bundles rather than whatever the last full // build left. Its own watch then covers later lib rewrites — those files are // in its module graph. - await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval) - // Through the shell's own `watch` script rather than vite's API: vite is not a - // repository-root dependency, and more importantly the vite root is its - // working directory — `resolve.dedupe` resolves react from that root, so - // running vite from anywhere but apps/web silently switches which react copy - // the bundle gets. - spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false) + const bundles = await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], options.pollInterval) + if (!shutdownRequested()) { + // Through the shell's own `watch` script rather than vite's API: vite is not a + // repository-root dependency, and more importantly the vite root is its + // working directory — `resolve.dedupe` resolves react from that root, so + // running vite from anywhere but apps/web silently switches which react copy + // the bundle gets. + spawnStage(supervisor, 'vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false) + // The same launch vector as the root `dsh` script, so the served Host runs + // from source exactly as `pnpm dsh web` would. + if (options.serve) { + spawnStage(supervisor, 'dsh web', process.execPath, [ + '--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', ...options.appArgs, + ], false) + } + console.log( + `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages` + + ` and ${String(libraryDirs.length)} statically linked library packages` + + (options.pollInterval !== undefined ? ` (polling ${String(options.pollInterval)}ms)` : '') + + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build` + + (options.serve ? ', serving through dsh web' : '') + + ':\n ' + + [...pluginDirs, ...libraryDirs].join('\n '), + ) + } - console.log( - `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages` - + ` and ${String(libraryDirs.length)} statically linked library packages` - + (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '') - + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n ` - + [...pluginDirs, ...libraryDirs].join('\n '), - ) + const exitCode = await shutdown.promise + await supervisor.stop({ signal: requested.signal, graceMs: STOP_GRACE_MS }) + for (const bundle of bundles) await bundle[Symbol.asyncDispose]() + process.exit(exitCode) } diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 1cd20f6c7f..f0565a0aff 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -2127,9 +2127,7 @@ "b50b6842b3565ea7f1441afb3214ab0ccf027df0e44bb485005935849c46f8ea": 1 }, "packages/subagent/subagent/src/catalog.ts": { - "29a67bf48723006b65c652af8a0ff30a4385bb14d651d91ca3059e1ddcb44fcf": 1, "3e704e331ed2fc5d4c0855e2455b31b82924c525f2e267416e393cdbd36c251b": 1, - "c3a7c8abe19131a8d98f4fb6a495b590e67a56cbf2e741caccfe730f8261edb0": 1, "fdc5fcade0ba1abbc426ccccb07a1ad4998253799b8561815047d95178441cdf": 1 }, "packages/subagent/subagent/src/internal.ts": { diff --git a/scripts/persistence-changes.spec.ts b/scripts/persistence-changes.spec.ts index 54b5a9588f..ab6f0e50e3 100644 --- a/scripts/persistence-changes.spec.ts +++ b/scripts/persistence-changes.spec.ts @@ -397,7 +397,63 @@ function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoo return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) } } +function versionedEvent(variants: readonly { version: number; mode: string; extra?: boolean }[]): PersistenceRoot { + const nodes: SchemaNode[] = [ + { kind: 'object', indices: [], properties: [ + { name: 'data', type: variants.length === 1 ? 3 : 1, optional: false }, + { name: 'type', type: 3 + variants.length * 3, optional: false }, + ] }, + { kind: 'union', types: variants.map((_, index) => 3 + index * 3) }, + { kind: 'primitive', type: 'string' }, + ] + for (const variant of variants) { + const index = nodes.length + nodes.push({ kind: 'object', indices: [], properties: [ + { name: 'version', type: index + 1, optional: false }, + { name: 'mode', type: index + 2, optional: false }, + ...(variant.extra ? [{ name: 'extra', type: 2, optional: false }] : []), + ] }, { kind: 'literal', value: variant.version }, { kind: 'literal', value: variant.mode }) + } + nodes.push({ kind: 'literal', value: 'example/value' }) + const schema = canonicalizeSchema(nodes, 0) + return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) } +} + describe('persistence change classification', () => { + it.each([1, 2])('acknowledges a higher payload version while preserving %s old variant(s) and the V4 checkpoint', (count) => { + const root = fixture() + const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }].slice(0, count) + const oldEvent = versionedEvent(variants) + const nextEvent = versionedEvent([...variants, { version: 1, mode: 'unknown', extra: true }]) + const before = { ...inventory({}, 4), roots: [...inventory({}, 4).roots.filter(root => root.kind !== 'event'), oldEvent] } + finalize(root, before) + const after = { ...before, roots: before.roots.map(root => root.kind === 'event' ? nextEvent : root) } + expect(classifyPersistenceChange(oldEvent, nextEvent)) + .toEqual([expect.objectContaining({ kind: 'payload-version-added', requiresVersionBump: false })]) + runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after) + verifyPersistenceChanges(root, after) + }) + + it('rejects changes to old payloads, missing versions, non-increasing versions, and removal of old readers', () => { + const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }] + const before = versionedEvent(variants) + for (const after of [ + versionedEvent([...variants, { version: 0, mode: 'unknown' }]), + versionedEvent([...variants, { version: -1, mode: 'unknown' }]), + versionedEvent([...variants, { version: 0.5, mode: 'unknown' }]), + versionedEvent([{ version: 0, mode: 'one-shot', extra: true }, variants[1]!, { version: 1, mode: 'unknown' }]), + versionedEvent([{ version: 1, mode: 'unknown' }]), + ]) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true) + const expanded = versionedEvent([...variants, { version: 1, mode: 'unknown' }]) + expect(classifyPersistenceChange(expanded, before).some(change => change.requiresVersionBump)).toBe(true) + expect(classifyPersistenceChange({ ...before, surface: true }, { ...expanded, surface: true }) + .some(change => change.requiresVersionBump)).toBe(true) + expect(classifyPersistenceChange({ ...before, kind: 'header' }, { ...expanded, kind: 'header' }) + .some(change => change.requiresVersionBump)).toBe(true) + const unversioned = unionBody([[{ name: 'mode', type: 2, optional: false }]]) + expect(classifyPersistenceChange(unversioned, expanded).some(change => change.requiresVersionBump)).toBe(true) + }) + it('treats a new optional payload subtree as one additive change even with required descendants', () => { const before = typeRoot('event:example/value', { value: 'string' }) const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } }) diff --git a/scripts/persistence-changes.ts b/scripts/persistence-changes.ts index 65f17b279f..d1f38225a9 100644 --- a/scripts/persistence-changes.ts +++ b/scripts/persistence-changes.ts @@ -66,6 +66,7 @@ const CHANGE_DESCRIPTIONS = { 'index-signature-changed': 'index signature changed', 'tuple-length-changed': 'tuple length changed', 'tuple-element-cardinality-changed': 'tuple element cardinality changed', + 'payload-version-added': 'event payload version added', 'union-variants-changed': 'union variants changed', 'source-policy-changed': 'source compatibility policy changed', 'attribution-kind-added': 'attribution-only source kind added', @@ -405,6 +406,27 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after: } const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode const newNode = newRoot.schema.nodes[newIndex] as SchemaNode + if (oldRoot.kind === 'event' && oldRoot.surface === false && scope === 'body' && path === `${key}.data`) { + const oldTypes = oldNode.kind === 'union' ? oldNode.types : [oldIndex] + const newTypes = newNode.kind === 'union' ? newNode.types : [newIndex] + const payloadVersion = (schema: CanonicalSchema, index: number): number | undefined => { + const node = schema.nodes[index] + if (node?.kind !== 'object') return undefined + const property = node.properties.find(property => property.name === 'version' && !property.optional) + const version = schema.nodes[property?.type ?? -1] + return version?.kind === 'literal' && typeof version.value === 'number' + && Number.isSafeInteger(version.value) && version.value >= 0 ? version.value : undefined + } + const versions = oldTypes.map(index => payloadVersion(oldRoot.schema, index)) + const oldHashes = new Set(oldTypes.map(index => fingerprint(oldRoot.schema, index, 0))) + const newHashes = new Set(newTypes.map(index => fingerprint(newRoot.schema, index, 1))) + const added = newTypes.filter(index => !oldHashes.has(fingerprint(newRoot.schema, index, 1))) + if (versions.every(version => version !== undefined) && added.length > 0 + && [...oldHashes].every(hash => newHashes.has(hash)) + && added.every(index => (payloadVersion(newRoot.schema, index) ?? -1) > Math.max(...versions))) { + return [describe(path, 'payload-version-added', false)] + } + } if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')] if (oldNode.kind === 'object' && newNode.kind === 'object') { const oldProps = new Map(oldNode.properties.map(property => [property.name, property])) diff --git a/scripts/session-snapshot-corpus-policy.spec.ts b/scripts/session-snapshot-corpus-policy.spec.ts index d1f317db33..0cfe2231b7 100644 --- a/scripts/session-snapshot-corpus-policy.spec.ts +++ b/scripts/session-snapshot-corpus-policy.spec.ts @@ -111,12 +111,14 @@ describe('recorded-session corpus policy', () => { ) it('bounds explicitly retained roles and requires a baseline/current majority', () => { + expect(assertSnapshotCorpusPolicy([ + baseline, current, + { ...completeV0, selectedVersions: Array(9).fill(0) }, ...adjacent, + ]).retainedRoles).toBe(11) expect(() => assertSnapshotCorpusPolicy([ - baseline, - current, - { ...completeV0, selectedVersions: Array(11).fill(0) }, - ...adjacent, - ])).toThrow(`Session corpus retains ${11 + adjacent.length} historical roles; maximum is 10`) + baseline, current, + { ...completeV0, selectedVersions: Array(10).fill(0) }, ...adjacent, + ])).toThrow('Session corpus retains 12 historical roles; maximum is 11') expect(() => assertSnapshotCorpusPolicy([ { ...baseline, selectedVersions: [3] }, completeV0, ...adjacent, ])).toThrow(`Session corpus requires a baseline/current majority; baseline=1, current=0, retained=${1 + adjacent.length}`) diff --git a/scripts/session-snapshot-corpus-policy.ts b/scripts/session-snapshot-corpus-policy.ts index e10e35506a..0f3b911c4e 100644 --- a/scripts/session-snapshot-corpus-policy.ts +++ b/scripts/session-snapshot-corpus-policy.ts @@ -22,7 +22,7 @@ export interface SnapshotCorpusGenerationSummary { } const RETAINED_BASELINE_VERSION = 3 -const MAX_RETAINED_ROLES = 10 +const MAX_RETAINED_ROLES = 11 const REQUIRED_V0_COVERAGE = new Set([ 'multi-hop', 'packed-row', diff --git a/scripts/session-snapshot-corpus.corpus.ts b/scripts/session-snapshot-corpus.corpus.ts index efa995e839..54d4124ad4 100644 --- a/scripts/session-snapshot-corpus.corpus.ts +++ b/scripts/session-snapshot-corpus.corpus.ts @@ -204,7 +204,7 @@ it('keeps V3 replay input plus bounded declared historical migration coverage', const summary = assertSnapshotCorpusPolicy(inventory) expect(summary.baselineRoles).toBeGreaterThan(0) expect(summary).toMatchObject({ - retainedRoles: 10, + retainedRoles: 11, retainedScenarios: 8, }) }) diff --git a/scripts/verify-application-entrypoints.spec.ts b/scripts/verify-application-entrypoints.spec.ts index 0c84043dfc..6ff6cb673a 100644 --- a/scripts/verify-application-entrypoints.spec.ts +++ b/scripts/verify-application-entrypoints.spec.ts @@ -99,8 +99,28 @@ describe('application entrypoints', () => { write(root, 'scripts/demo-ptc.mjs', "spawn('node', ['packages/example/app/src/bin.ts'])\n") expect(applicationEntrypointViolations(root)).toEqual([ - 'scripts/demo-ptc.mjs: application demo wrapper must launch apps/cli/src/bin.ts', - 'scripts/demo-ptc.mjs: application demo wrapper must not launch a package entry directly', + 'scripts/demo-ptc.mjs: application launcher wrapper must launch apps/cli/src/bin.ts', + 'scripts/demo-ptc.mjs: application launcher wrapper must not launch a package entry directly', + ]) + }) + + it('rejects a start:web script that bypasses the dsh launcher', () => { + const root = fixture() + write(root, 'package.json', JSON.stringify({ scripts: { 'start:web': 'node packages/example/app/src/bin.ts web' } })) + + expect(applicationEntrypointViolations(root)).toEqual([ + 'package.json scripts.start:web: application launcher script must launch apps/cli/src/bin.ts', + 'package.json scripts.start:web: application launcher script must not launch a package entry directly', + ]) + }) + + it('rejects a dev:web wrapper that serves without the dsh launcher', () => { + const root = fixture() + write(root, 'package.json', JSON.stringify({ scripts: { 'dev:web': 'tsx scripts/dev-web.ts --poll' } })) + write(root, 'scripts/dev-web.ts', "execa('vite', ['build', '--watch'])\n") + + expect(applicationEntrypointViolations(root)).toEqual([ + 'scripts/dev-web.ts: application launcher wrapper must launch apps/cli/src/bin.ts', ]) }) diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index 728dd0832f..b629e7e9ab 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -18,7 +18,7 @@ interface RootManifest { readonly scripts?: Record } -interface DemoPolicy { +interface LauncherPolicy { readonly kind: 'dsh-direct' | 'dsh-wrapper' readonly wrapper?: string } @@ -48,10 +48,16 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ ['python/sdk-runtime/runtime-bootstrap.mjs', 'private packaging-only runtime dispatcher'], ]) -/** Root demos are application wrappers and therefore must visibly select dsh. */ -const ROOT_DEMO_POLICIES = new Map([ +/** + * Root scripts that start an application must visibly select dsh: every `demo:*` + * script needs a row here, and the Web start and development scripts are pinned + * by name. + */ +const ROOT_LAUNCHER_POLICIES = new Map([ ['demo:ptc', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-ptc.mjs' }], ['demo:inspector', { kind: 'dsh-direct' }], + ['start:web', { kind: 'dsh-direct' }], + ['dev:web', { kind: 'dsh-wrapper', wrapper: 'scripts/dev-web.ts' }], ]) const SOURCE_PATTERNS = [ @@ -131,22 +137,23 @@ function referencesPackageEntry(source: string): boolean { return /packages\/[^/\s'"`]+\/[^/\s'"`]+\/(?:src|lib)\/[^\s'"`]+/.test(source) } -function rootDemoViolations(root: string): string[] { +function rootLauncherViolations(root: string): string[] { const manifestPath = resolve(root, 'package.json') if (!existsSync(manifestPath)) return [] const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as RootManifest const failures: string[] = [] for (const [name, commandValue] of Object.entries(manifest.scripts ?? {}).sort(([left], [right]) => left.localeCompare(right))) { - if (!name.startsWith('demo:')) continue - const command = typeof commandValue === 'string' ? commandValue : '' - const policy = ROOT_DEMO_POLICIES.get(name) + const policy = ROOT_LAUNCHER_POLICIES.get(name) if (policy === undefined) { - failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`) + if (name.startsWith('demo:')) { + failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`) + } continue } + const command = typeof commandValue === 'string' ? commandValue : '' if (policy.kind === 'dsh-direct') { - if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application demo must launch apps/cli/src/bin.ts`) - if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application demo must not launch a package entry directly`) + if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application launcher script must launch apps/cli/src/bin.ts`) + if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application launcher script must not launch a package entry directly`) continue } const wrapper = policy.wrapper @@ -156,12 +163,12 @@ function rootDemoViolations(root: string): string[] { } const wrapperPath = resolve(root, wrapper) if (!existsSync(wrapperPath)) { - failures.push(`${wrapper}: classified demo wrapper is missing`) + failures.push(`${wrapper}: classified launcher wrapper is missing`) continue } const source = readFileSync(wrapperPath, 'utf8') - if (!referencesDshCli(source)) failures.push(`${wrapper}: application demo wrapper must launch apps/cli/src/bin.ts`) - if (referencesPackageEntry(source)) failures.push(`${wrapper}: application demo wrapper must not launch a package entry directly`) + if (!referencesDshCli(source)) failures.push(`${wrapper}: application launcher wrapper must launch apps/cli/src/bin.ts`) + if (referencesPackageEntry(source)) failures.push(`${wrapper}: application launcher wrapper must not launch a package entry directly`) } return failures } @@ -175,7 +182,7 @@ export function applicationEntrypointViolations(root: string): string[] { return [ ...manifestBinViolations(root), ...executableSourceViolations(root), - ...rootDemoViolations(root), + ...rootLauncherViolations(root), ] } diff --git a/scripts/verify-package-meta.spec.ts b/scripts/verify-package-meta.spec.ts index fbdd05daf8..ac82e7e511 100644 --- a/scripts/verify-package-meta.spec.ts +++ b/scripts/verify-package-meta.spec.ts @@ -44,6 +44,57 @@ it('does not require locale resources for exported package name and description' expect(packageMetaProblems(root)).toEqual([]) }) +it.each([undefined, ['art'], ['art/*.svg'], ['./art/icon.svg']])('accepts a published package icon without locale resources: %j', (files) => { + manifest({ icon: './art/icon.svg', files }) + file('art/icon.svg', '') + expect(packageMetaProblems(root)).toEqual([]) +}) + +it.each([{ files: ['lib'] }, { files: ['art', '!art/icon.svg'] }])('rejects an icon omitted from package publication: %j', ({ files }) => { + manifest({ icon: './art/icon.svg', files }) + file('art/icon.svg', '') + expect(packageMetaProblems(root).join('\n')).toContain('files must include art/icon.svg') +}) + +it.each([null, false, 1, '', './icon.gif', '/tmp/icon.svg', './missing.png', '../outside.svg'])('validates icon declarations without locale resources: %j', (icon) => { + manifest({ icon }) + file('../outside.svg', '') + expect(packageMetaProblems(root).join('\n')).toContain('Plugin metadata for @test/plugin:') +}) + +it('requires the root icon declaration to be exported', () => { + manifest({ icon: './icon.svg', exports: { '.': './entry.js' }, files: ['icon.svg'] }) + file('icon.svg', '') + expect(packageMetaProblems(root).join('\n')).toContain('exports must expose its icon declaration through @test/plugin/package.json') +}) + +it.each([ + { './search/package.json': './resources/search/manifest.json' }, + { './*/package.json': './resources/*/manifest.json' }, + { './*': './resources/*' }, +])('validates icons of independent exported manifests: %j', (exports) => { + manifest({ exports, files: ['resources'] }) + const document = './*' in exports ? 'resources/search/package.json' : 'resources/search/manifest.json' + json(document, { icon: './icon.webp' }) + file('resources/search/icon.webp', 'webp') + expect(packageMetaProblems(root)).toEqual([]) + json(document, { icon: './missing.png' }) + expect(packageMetaProblems(root).join('\n')).toContain('missing.png') +}) + +it('requires publication of both an exported icon manifest and its image', () => { + manifest({ exports: { './search/package.json': './resources/search.json' }, files: ['resources/icon.svg'] }) + json('resources/search.json', { icon: './icon.svg' }) + file('resources/icon.svg', '') + expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/search.json') +}) + +it('ignores icon-like fields in unrelated source JSON', () => { + manifest() + json('data.json', { icon: false }) + expect(packageMetaProblems(root)).toEqual([]) +}) + it('excludes tests, installed dependencies, and build output from metadata discovery', () => { manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] }) json('tests/fixtures/locale/en.json', { meta: { title: false } }) @@ -91,6 +142,30 @@ it.each(['en', 'zh'])('rejects %s metadata resolved into build output without re expect(problems).not.toContain('Plugin metadata for') }) +it('does not read built locale JSON while checking a source icon declaration', () => { + manifest({ + icon: './icon.svg', files: ['icon.svg'], + exports: { './package.json': './package.json', './locale/en.json': './lib/locale/en.json' }, + }) + file('icon.svg', '') + file('lib/locale/en.json', '{') + json('locale/en.json', { meta: { title: 'Plugin' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('exports must expose locale/en.json') + expect(problems).not.toContain('Plugin metadata for') +}) + +it.each(['lib', 'tests'])('rejects an icon declaration when its only locale is excluded source: %s', (directory) => { + manifest({ + icon: './missing.svg', files: ['lib'], + exports: { './package.json': './package.json', './locale/en.json': `./${directory}/locale/en.json` }, + }) + file(`${directory}/locale/en.json`, '{}') + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('@test/plugin: icon metadata requires locale resources to resolve to source JSON') + expect(problems).not.toContain('Plugin metadata for') +}) + it('reads static metadata without evaluating the plugin entry', () => { manifest({ exports: { '.': './entry.js', './locale/*.json': './locale/*.json' } }) file('entry.js', "import { writeFileSync } from 'node:fs'; writeFileSync(new URL('./executed', import.meta.url), 'yes'); throw new Error('plugin entry executed')") diff --git a/scripts/verify-package-meta.ts b/scripts/verify-package-meta.ts index f5a486678f..de5720c6db 100644 --- a/scripts/verify-package-meta.ts +++ b/scripts/verify-package-meta.ts @@ -9,12 +9,14 @@ interface Manifest { name: string exports?: unknown files?: string[] + icon?: unknown } interface SourceJson { file: string metadata: boolean invalid: boolean + icon?: unknown } function sourceJsonFiles(dir: string): SourceJson[] { @@ -29,7 +31,7 @@ function sourceJsonFiles(dir: string): SourceJson[] { if (typeof contents !== 'object' || contents === null || Array.isArray(contents)) { return { file, metadata: false, invalid: true } } - return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false } + return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false, icon: 'icon' in contents ? contents.icon : undefined } }) } @@ -77,7 +79,10 @@ function packageProblems(manifestPath: string): string[] { const exported = typeof pkg.exports === 'object' && pkg.exports !== null ? Object.entries(pkg.exports) : [] for (const [key, target] of exported) { if (!key.startsWith('.')) continue - if (!key.includes('*')) candidates.add(key) + if (!key.includes('*')) { + candidates.add(key) + if (key.endsWith('/package.json')) candidates.add(key.slice(0, -'/package.json'.length)) + } const plugin = pluginOf(key) if (plugin !== undefined && !plugin.includes('*')) candidates.add(plugin) for (const pattern of targetsOf(target)) { @@ -85,6 +90,7 @@ function packageProblems(manifestPath: string): string[] { const value = substitution(pattern, `./${document.file}`) if (value === undefined) continue const request = key.replaceAll('*', value) + if (request.endsWith('/package.json')) candidates.add(request.slice(0, -'/package.json'.length)) const owner = pluginOf(request) if (owner === undefined) continue candidates.add(owner) @@ -116,6 +122,26 @@ function packageProblems(manifestPath: string): string[] { const file = lookup(resourceOf(filename)) return file === undefined ? [] : [{ filename, file }] }) + const iconManifest = lookup(`${specifier}/package.json`) + const iconDocument = iconManifest === undefined ? undefined : byPath.get(iconManifest) + let iconChecked = false + if (candidate === '.' && pkg.icon !== undefined && iconManifest !== join(dir, 'package.json')) { + problems.push(`${manifestPath}: exports must expose its icon declaration through ${pkg.name}/package.json`) + } + if (iconDocument?.icon !== undefined && resources.every(({ file }) => byPath.has(file))) { + iconChecked = true + const meta = readPluginMeta(specifier, parentURL) + if (meta?.error !== undefined) problems.push(meta.error) + if (meta?.icon !== undefined && typeof iconDocument.icon === 'string') { + const iconFile = relative(dir, resolve(dirname(join(dir, iconDocument.file)), iconDocument.icon)).replaceAll('\\', '/') + if (pkg.files !== undefined && !published(iconFile, pkg.files)) problems.push(`${manifestPath}: files must include ${iconFile}`) + if (iconDocument.file !== 'package.json' && pkg.files !== undefined && !published(iconDocument.file, pkg.files)) { + problems.push(`${manifestPath}: files must include ${iconDocument.file}`) + } + } + } else if (iconDocument?.icon !== undefined) { + problems.push(`${specifier}: icon metadata requires locale resources to resolve to source JSON`) + } if (!resources.some(({ file }) => byPath.get(file)?.metadata || byPath.get(file)?.invalid)) continue for (const { file } of resources) claimed.add(file) const english = lookup(resourceOf('en.json')) @@ -138,7 +164,7 @@ function packageProblems(manifestPath: string): string[] { problems.push(`${resourceOf(filename)}: ${file} must share the English locale directory ${directory}`) } } - if (sourceResources) { + if (sourceResources && !iconChecked) { const meta = readPluginMeta(specifier, parentURL) if (meta?.error !== undefined) problems.push(meta.error) } diff --git a/snapshots/web/preset-migration/session.1.v2.jsonl b/snapshots/web/preset-migration/session.1.v2.jsonl new file mode 100644 index 0000000000..47ef5e21a9 --- /dev/null +++ b/snapshots/web/preset-migration/session.1.v2.jsonl @@ -0,0 +1,3 @@ +{"type":"session","version":2,"id":"{{session:2}}","createdAt":2,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":1,"agentPreset":"code","origin":"subagent","parentSession":"{{session:1}}"} +{"type":"subagent/descriptor","data":{"version":3,"provider":"spawn","mode":"one-shot","label":"historical child"}} +{"type":"feedback/record","data":{"text":"Child history remains readable independently."}} diff --git a/tsconfig.host.json b/tsconfig.host.json index c1088e0ae8..f15cff939c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -8,7 +8,8 @@ "rewriteRelativeImportExtensions": false }, "files": [ - "packages/client/ui-settings-general/src/client/desktop-update-bridge.ts" + "packages/client/ui-settings-general/src/client/desktop-update-bridge.ts", + "packages/client/ui-sidebar-browser/src/index.ts" ], "include": [ "apps/web/product-isolation.ts",