diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index a72d46d4f6..c3f4068862 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.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-06-14-session-persistence.md -2026-06-14-session-persistence.md: 137b2b01126214629952812f3dd3b71985a3acda -2026-06-14-session-persistence.zh.md: 0f00902f5d6d60073bb56aabaf420bf2042e08fc +2026-06-14-session-persistence.md: 00e129e57c7144fd62eec26f5854ee21dec4e964 +2026-06-14-session-persistence.zh.md: 1c98d5771819e8776d9f5cae4a147d2016ee5978 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 137b2b0112..00e129e57c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -14,23 +14,23 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append Persistence is an abstract **capability seam** ([capability seams](2026-06-13-capability-seams.md), the `dsh-bash` template), not loop or core logic: -1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. +1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. 2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Eligible `assistant/chunk` delta runs use packed rows by default; [checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable. Key choices recorded here because they are durable, contested, and surprising: -- **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but `load` reconstructs the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. -- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation. +- **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and validation of `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. +- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. +- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), and reads use SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, logical interrupted-turn closure, single committed repair, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, JSONL validates the decoded header, and SQLite stores it in a strict `INTEGER` column. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) -- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. +- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` obtains the exact unpublished Session through `ctx.sessionPersistence.prepare()`, publishes it under the persisted id, and continues its projections. The [Session preparation decision](2026-08-05-session-preparation.md) owns reuse between history inspection and resume. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; `load` rejects any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. +Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. ## Consequences -Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every `assistant/chunk` survives exactly even when JSONL packs several into one storage row. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open. +Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every `assistant/chunk` survives exactly even when JSONL packs several into one storage row. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open. diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index 0f00902f5d..1c98d57718 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -14,23 +14,23 @@ Status: implemented 持久化是一个抽象的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-bash` 模板),而非循环或核心逻辑: -1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 +1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md)是默认物理编码,也可通过配置使用原始行。 以下关键选择记录于此,因为它们长期有效、存在争议且出人意料: -- **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但 `load` 会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 -- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。 +- **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些 closer;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),读取使用 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、逻辑关闭中断轮次、修复只提交一次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并以原样恢复的 id 注册新 agent。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 +格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(冷准备时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 ## 后果 -新增两个包,以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端可在同一接口下替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。 +新增两个包,以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端可在同一接口下替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束每个后端。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 061512da0d..7216c38d38 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.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-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 4632351a6f39c44c9ba8af58d508d4665b9e9279 -2026-06-18-shared-persistence-write-coordinator.zh.md: f5a70d7d6e7ab76663620ca8d416c671f81e2f8f +2026-06-18-shared-persistence-write-coordinator.md: 66b73b60ceec9497f1f1226747b8cebd831eb426 +2026-06-18-shared-persistence-write-coordinator.zh.md: 424ce6ec7384e8af7b979a29f58c31379a1d1850 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 4632351a6f..66b73b60ce 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -10,9 +10,9 @@ English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) ## Decision -Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its stateful public methods (`create`/`append`/`load`/`inspect`) to it. Backend-owned metadata and revision listing bypass the coordinator. +Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its stateful public methods (`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`) to it. Backend-owned metadata and revision listing bypass the coordinator. -Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including the non-mutating `inspect` contract used by read models. +Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including immutable logical inspection and the default preparation fallback through `load`. The coordinator holds one controller for each exact live `Session`; the controller combines initialization, pending events, and the shared flush promise. Each `session/event` starts an eager drain, and `session/flush` observes quiescence rather than initiating the ordinary write path. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns this lifecycle. @@ -23,9 +23,9 @@ The coordinator retires a session from `session/disposed`: it waits for the cont Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage: - `name` — backend label for the dispose-failure `AggregateError`. -- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. +- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Preparation, logical load/inspection, physical suffix reads, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. - `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). -- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). +- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `prepare`/`load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). - `list()` — list all stored metadata. - `close?()` — optional lifecycle teardown (SQLite closes its db handle; JSONL omits it), awaited in the dispose effect AFTER the quiescence drain so a close failure never masks a drain error. @@ -35,7 +35,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t ## Testing -The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` leaves interrupted logs and revisions unchanged before `load` performs recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. Coordinator-specific tests cover eager follow-up batches, live-controller cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker. +The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. `persistence.spec.ts` and `preparations.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, eager follow-up batches, live-controller cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker. ## Alternatives considered @@ -44,4 +44,4 @@ The shared `runPersistenceContract` (public-API contract) runs for every backend ## Consequences -The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, and non-mutating inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn cannot race a new live owner by committing interruption closers. New backends implement storage primitives rather than copy the eager write lifecycle. +The coordinator adds one indirection, an opaque torn marker, detached session-retirement tasks, and bounded prepared Session state, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, preparation, and immutable inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn does not commit interruption closers; the [Session preparation decision](2026-08-05-session-preparation.md) owns reuse, reservation, and publication. New backends implement storage primitives rather than copy the eager write lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index f5a70d7d6e..424ce6ec73 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其有状态的公开方法(`create`/`append`/`load`/`inspect`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。 +将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其有状态的公开方法(`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。 -组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括供读模型使用、不修改状态的 `inspect` 契约。 +组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 协调器为每个存活的 `Session` 实例持有一个控制器;该控制器统合初始化、待处理事件与共享 flush promise。每个 `session/event` 都会立即启动排空,而 `session/flush` 只观察完全停稳,不会发起常规写入路径。[flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义该生命周期。 @@ -23,9 +23,9 @@ Status: implemented 五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 -- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 +- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。准备、逻辑加载/检查、物理后缀读取、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 - `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——二者之间发生崩溃时,不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 -- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 +- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `prepare`/`load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 - `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于排空至完全停稳之后被 await,因此 close 失败不会掩盖排空错误。 @@ -35,7 +35,7 @@ Status: implemented ## 测试 -共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明在 `load` 执行恢复之前,`inspect` 会保持被中断的日志与修订版本不变。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空,以及崩溃尾部修复。协调器专属测试覆盖立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。 +共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。 ## 曾考虑的替代方案 @@ -44,4 +44,4 @@ Status: implemented ## 后果 -协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查与不修改状态的检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时,不会因提交中断 closers 而与新的存活所有者产生竞态。新后端只需实现存储原语,而无需复制立即写入生命周期。 +协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断 closers;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制立即写入生命周期。 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 9c5d00eae0..820299cf2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc -2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc +2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3 +2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index cce649976c..ca56c77a09 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -20,7 +20,7 @@ AgentLoop privately owns one `TurnCancellation` per prospective turn. It install The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. -The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. +The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. `ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. @@ -44,7 +44,7 @@ Initiator-scope tests assert that every hook still observes the exact Agent and **Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning. -**Expose public turn or step context wrappers.** Existing positional seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority. +**Expose public turn or step context wrappers.** Existing seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority. **Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 6f8b83fdb4..bf410e5c72 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 @@ -44,7 +44,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 **现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。 -**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 +**公开轮次或步骤上下文包装类型。** 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 **在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 20c1d99991..5376a626e6 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-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 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: b306f3b155d9d9208066c3f25ad2c4fb4683b1ee -2026-07-19-gui-web-client-architecture.zh.md: 28632667c45b360eb2bc5f0d06f10b9df910770d +2026-07-19-gui-web-client-architecture.md: 1a91d88818c374a1637b546fb3ddf6647af68570 +2026-07-19-gui-web-client-architecture.zh.md: 5c0bacde9836d45812895f5d9c89a0e8974ed7a1 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index b306f3b155..1a91d88818 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. +There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency, independently from `ConversationService` ([decision](2026-08-05-slot-declaration-injection.md)). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 28632667c4..5c0bacde98 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 +slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`;声明本身就是加载与重载依赖,不依赖 `ConversationService`([决策](2026-08-05-slot-declaration-injection.md))。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml index d63e6d4b88..acb9d72ef1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md -2026-07-19-zstandard-jsonl-session-logs.md: 287ec94a91101850e9343d36ffd27870daf1333b -2026-07-19-zstandard-jsonl-session-logs.zh.md: 4e578432640651de1eb1977229b7cdd462766c24 +2026-07-19-zstandard-jsonl-session-logs.md: 93fc20f931c75552352834b9340e7d38680d4254 +2026-07-19-zstandard-jsonl-session-logs.zh.md: 061d7fcb55c775eed10e99bae47777d32cc8eee1 diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md index 287ec94a91..93fc20f931 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md @@ -28,7 +28,7 @@ First materialization compresses the two initial frames before opening the tempo ### Read, listing, and crash recovery -A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are decompressed independently and sequentially with Node's default `ZSTD_e_end`, which requires frame completion and validates their checksums, and their plaintext is passed to the existing JSONL scanner. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects. +A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are independently checksum-validated and passed through the [large-session restore pipeline](2026-08-05-large-session-jsonl-restore-pipeline.md), which owns decoder reuse, cooperative yielding, and incremental JSONL scanning. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects. Listing reads in bounded chunks only until the first complete frame is available, validates and decompresses that header frame, and never reads an event frame. The dedicated header frame therefore preserves metadata-only listing even for very large session logs. @@ -53,5 +53,5 @@ The shared persistence and coordinator contracts run against both encodings. Bac - Ordinary session roots store `.jsonl.zstd` and retain append-only, fsync, rollback, and interrupted-turn recovery semantics. - Raw JSONL remains a deliberate configuration, but changing encoding requires a fresh/separate root or selecting the mode that matches existing artifacts. - One frame per durable batch adds bounded framing/checksum overhead and allows header-only listing plus repair from an exact append boundary. -- External tools must understand concatenated Zstandard frames or consume raw-mode artifacts; generic one-shot Node decompression reads only the first independent frame, so backend reads walk frames explicitly. +- External tools must understand concatenated Zstandard frames or consume raw-mode artifacts; generic one-shot Node decompression reads only the first independent frame, so backend reads walk frames through the [restore pipeline](2026-08-05-large-session-jsonl-restore-pipeline.md). - The implementation depends on Node's experimental built-in Zstandard API without an npm dependency; the supported-version compatibility gate makes drift visible. diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md index 4e57843264..061d7fcb55 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md @@ -28,7 +28,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量 ### 读取、列举与崩溃恢复 -帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。后端使用 Node 默认的 `ZSTD_e_end` 独立且按顺序解压完整帧;该模式要求帧完整并验证各帧校验和,再把明文交给既有 JSONL 扫描器。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。 +帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。完整帧会独立验证校验和,再进入[大型会话恢复流水线](2026-08-05-large-session-jsonl-restore-pipeline.md);该流水线负责复用解码器、协作式让出事件循环和增量扫描 JSONL。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。 列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。 @@ -53,5 +53,5 @@ CLI、ACP 与 stdio 应用包公开对称的 `persistenceCompression` 透传配 - 普通会话根目录存储 `.jsonl.zstd`,并保留仅追加、fsync、回滚与中断轮次恢复语义。 - 原始 JSONL 仍是显式配置,但切换编码需要使用全新或单独根目录,或者选择与既有产物匹配的模式。 - 每个持久批次一个帧会增加有界的帧与校验和开销,同时支持仅头部列举和从精确追加边界开始修复。 -- 外部工具必须理解串联的 Zstandard 帧,或者消费原始模式产物;Node 通用的一次性解压只读取第一个独立帧,因此后端读取会显式遍历各帧。 +- 外部工具必须理解串联的 Zstandard 帧,或者消费原始模式产物;Node 通用的一次性解压只读取第一个独立帧,因此后端读取会通过[恢复流水线](2026-08-05-large-session-jsonl-restore-pipeline.md)遍历各帧。 - 实现依赖 Node 的实验性内置 Zstandard API,但不增加 NPM 依赖;受支持版本兼容性门禁会暴露 API 漂移。 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index 40d63fcff3..46ec2eb556 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md -2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6 -2026-07-22-slot-type-chain-implementation.zh.md: 8ca6781e42764fc8d7f7de0f9f25ca6c110d4be0 +2026-07-22-slot-type-chain-implementation.md: 2f0ec32766100e492c68c474f8798be3df0a3d15 +2026-07-22-slot-type-chain-implementation.zh.md: 75e89d3f57b96a1699981123e8361c775db2b8a7 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index e88361701f..2f0ec32766 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -36,6 +36,8 @@ There is no separate slot-definition API. The `children` object both **declares Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes; a chain registration missing its `select`. +A contributor whose activation order is independent from the declaring entry uses `ctx.slots.inject(key, callback)` and keeps direct `register()` fail-loud. The declaration, contributor, replacement, and failure lifetimes are specified by the [slot declaration injection decision](2026-08-05-slot-declaration-injection.md). + `SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type"). ### Component props: four shares, each from its own source of truth diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 8ca6781e42..75e89d3f57 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -36,6 +36,8 @@ ctx.slots.register({ 对等原则:**声明子 slot 的 entry 独占渲染这些子 slot 的权力**,全部在 register 时确定(配置错误会在装载时明确失败;渲染热路径不再校验)。装载即炸的情形:第二个 entry 声明已被声明的 slot;向未声明的 slot register;同一个 store 句柄挂到两个 scope 之下;chain 注册缺 `select`。 +激活顺序独立于声明条目的贡献方使用 `ctx.slots.inject(key, callback)`,并让直接调用 `register()` 继续大声失败。声明、贡献方、替换与失败各自的生命周期由 [slot 声明注入决策](2026-08-05-slot-declaration-injection.md) 规定。 + `SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。 ### 组件 props:四份额,各有唯一真源 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index add7df1e61..90d13fcfea 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md -2026-07-23-toolview-dissolution.md: 406e5c181aabb635f9d6dcb12d8a9b8b6697368e -2026-07-23-toolview-dissolution.zh.md: f42881c5f2e4c661d7fa40bfca7d0b53c1beef5e +2026-07-23-toolview-dissolution.md: 97d8beb4de43d9bc6348d942e5460d0321592b32 +2026-07-23-toolview-dissolution.zh.md: db93c6252d5d42d1fd85ce81ad430d95f4324cf2 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index 406e5c181a..97d8beb4de 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -14,7 +14,7 @@ After the view ring dissolved into the slot system, the client kept exactly one The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. -Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. +Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin using `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration itself governs activation and replacement, without a false `ConversationService` edge ([decision](2026-08-05-slot-declaration-injection.md)). The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. @@ -34,4 +34,4 @@ Four behavioral deltas were accepted deliberately, not overlooked. Cross-view ap ## Consequences -The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override), plus one subtlety the load-order seam carries: registrant plugins must declare `inject: ['conversation']` to sequence after the slot declaration, a convention the seam makes correct by construction but does not statically force on third parties. +The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override). Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index f42881c5f2..db93c6252d 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -14,7 +14,7 @@ Status: implemented 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 -落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `..` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 +落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方是使用 `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))` 的普通插件;声明本身控制激活与替换,不再引入虚假的 `ConversationService` 依赖([决策](2026-08-05-slot-declaration-injection.md))。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 @@ -34,4 +34,4 @@ registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘 ## Consequences -client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖),外加加载序缝携带的一处微妙:注册方插件须声明 `inject: ['conversation']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制。 +client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖)。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 37eccb0c95..9c8308be74 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: aeefbe22a397e3d7ffb9f6427a3c70c8c8e8b940 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 056d50d45cef891e0e635d8bb4f2e73064ccdb87 +2026-07-25-web-client-session-scope-and-provide-channel.md: 3c51f06fca23a495f0fbc0cc4f1c289edea07b3b +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 06fc1005785d9d11b52839f91c3bb4b99cad7d63 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index aeefbe22a3..3c51f06fca 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -93,7 +93,7 @@ Slot scope is the closed set `root | session-maybe | session`: - `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing. - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 056d50d45c..06fc100578 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -93,7 +93,7 @@ slot scope 是闭集 `root | session-maybe | session`: - `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 -`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index a52995c855..f22f2340ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 977df6508e1a1cd54cf1ddb469a6bfb835f60071 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: f70065c8b356b2ed5ca6ab317fbdeb5177f058fa +2026-07-25-web-input-machine-and-slash-pipeline.md: 39ef214a94fcd019f535fb60136d5dcc09b54e60 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 9b0ca0cadbc5e0212048b165f0d60d567a5639ad diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 977df6508e..39ef214a94 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -69,9 +69,9 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": ### hub / facade: the resident shell and the strict-session input body - The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-slash or the command surfaces, input still sends and receives normally — graceful degradation. -- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. +- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears. - The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. -- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. +- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. - Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current. - The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee. @@ -93,9 +93,10 @@ skill/@subagent references skip the placeholder + occurrence identity chain — ### The slot system -`conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The child slots are all declared by ui-conversation's conversation registration: +`conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The root registration renders the header outlet above its resident scrollport and the body outlet inside it, before the resident composer seat. The child slots are all declared by ui-conversation's conversation registration: -- `conversation.session` (single) — the strict-session header, view ring, and chat store; rebuilt when the session id switches. +- `conversation.session.header` (single) — strict-session breadcrumb, view tabs, and header actions above the resident scrollport. +- `conversation.session` (single) — the strict-session view ring and draft mirror inside the resident scrollport. Header and body share the same session-scoped chat store; each is rebuilt when the session id switches. - `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival. - `conversation.input.overlay` — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId. - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. @@ -128,7 +129,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ ## Consequences -- One resident conversation shell carries no-session/blank/active: no session → blank guarantees only the outer frame's React identity, allowing the disabled textarea to be replaced by the strict InputBar; the same blank session → engaging/active keeps the InputBar and the textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. +- One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and textarea; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. - The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; `@` references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract. - Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests. - Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index f70065c8b3..9b0ca0cadb 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -69,9 +69,9 @@ occurrence 表与 chip 三投影: ### hub / facade:常驻外壳与严格 session 输入体 - hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-slash/命令面时输入正常收发,优雅降级。 -- 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。 +- 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;Session 出现后,彼此独立的严格 session header 和 body outlet 只填入这些固定区域。 - composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 -- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 +- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`:summary 已证实为空的 Session 在任何 open state 下都保持 Hero,未经证实的 Session 则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 - 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。 - Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。 @@ -93,9 +93,10 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 ### slot 体系 -`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 session,Hero Workspace picker 保持 root。子 slot 均由 ui-conversation 的 conversation 注册声明: +`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 session,Hero Workspace picker 保持 root。root 注册把 header outlet 渲染在常驻 scrollport 上方,把 body outlet 渲染在其内部、常驻 composer seat 之前。子 slot 均由 ui-conversation 的 conversation 注册声明: -- `conversation.session`(single)——严格 session 的 header、view ring 与 chat store;session id 切换时重建。 +- `conversation.session.header`(single)——常驻 scrollport 上方严格 session 的 breadcrumb、view tab 与 header action。 +- `conversation.session`(single)——常驻 scrollport 内严格 session 的 view ring 与 draft mirror。header 和 body 共享同一个 session scope chat store;session id 切换时各自重建。 - `conversation.composer.bar`(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。 - `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自 per-session controller。 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 @@ -128,7 +129,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 ## 后果 -- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 只保证大框架 React identity,允许 disabled textarea 替换为严格 InputBar;同一 blank session → engaging/active 保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。 +- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与 textarea;只有严格 session header 和 body outlet 开始承载内容。同一 blank session → engaging/active 也保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。 - 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结契约。 - 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。 - 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml index 70d44e1767..9994bcb3b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md -2026-07-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 -2026-07-27-dispose-ladder-to-consumer.zh.md: 7fff744e64109549a65d4f5bb17ff2d6ddfc6888 +2026-07-27-dispose-ladder-to-consumer.md: e9af88e8e7ef962213a74e96a249241cbe8d5994 +2026-07-27-dispose-ladder-to-consumer.zh.md: 89f8e107c56d42787c59bc6f8fa8fc7b3ef73208 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md index 97b551ff50..e9af88e8e7 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -10,7 +10,7 @@ English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) ## Decision -The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface. +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call `terminate()`, whose SIGTERM→spec-grace→SIGKILL escalation already owns the signal timer, and await an unbounded `waitForExit()` for the subprocess owner's whole-tree exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real tree exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. ## Alternatives considered @@ -20,4 +20,4 @@ The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(c ## Consequences -Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy. +Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns the termination window and final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false before escalation and an unbounded whole-tree join after it) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md index 7fff744e64..89f8e107c5 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的完全停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已拥有信号定时器),再无界等待 `waitForExit()`,由子进程责任方证明整棵进程树已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认进程树真正退出所需的停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 ## 曾考虑的替代方案 @@ -20,4 +20,4 @@ Status: implemented ## 后果 -换来的是:seam 少了一个方法和一个类型;实现只需提供四个动词,无需提供拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 +买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止时间窗与最终的整树退出等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前有界 `waitForExit` 返回假,升级后无界等待整棵进程树退出),而非组合后的策略。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml new file mode 100644 index 0000000000..9300571e28 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-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 .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41 +2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md new file mode 100644 index 0000000000..d75b6bdb91 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -0,0 +1,67 @@ +# Agent Note: pi-ai routes are declared providers, not catalog lookups + +Status: implemented + +English | [中文](2026-08-03-pi-ai-declared-provider-catalog.zh.md) + +## Problem + +`dsh-llm-pi-ai` treated the pi-ai package's generated catalog as the boundary of what could be configured. A route key had to name an installed provider (`resolveProfiles` rejected anything else), model listing returned `getBuiltinModels(provider)` verbatim, and request-time model resolution looked the id up in that same catalog and overrode only `baseURL`. Three consequences followed, and all three were dead ends rather than gaps: an OpenAI-compatible gateway, a self-hosted server, or a provider newer than the installed catalog could not be configured at all; a model the catalog had not caught up with failed with `UNKNOWN_MODEL` even against a correct endpoint; and a model's context window and output cap were whatever the pinned pi-ai release said, so a deployment could neither correct a stale value nor supply one for a model pi-ai had never described. Upgrading the package was the only way to move any of it. + +The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/compat`, an entry point whose own module documentation declares it a temporary compatibility surface — its catalog reads are `@deprecated`, and it is deleted when pi-ai finishes its `ModelManager` migration. The three configuration limits and the deprecated dependency have the same fix, because pi-ai's supported runtime (`createModels()` / `createProvider()`) is built around a provider being *declared* rather than looked up. + +## Decision + +A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: + +- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. +- `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. +- `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`. +- A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked. + +### Snapshots, not a shared collection + +`Models.streamSimple()` resolves its provider lazily, when the returned stream is first consumed — which is after the adapter has awaited the route's credential. A single collection mutated in place would therefore let a request that started under one configuration finish under another, or fail on a provider that no longer exists, even though `llm.prepareCall()` already froze that step's config and captured its adapter registration. A configuration change builds a *new* collection and leaves the one in use alone, so the seam's per-step freeze holds all the way down: switching models mid-reply takes effect on the next step, never inside the one in flight. + +### The directory replaces atomically + +The configurable-provider directory follows the profiles, so it changes whenever a declared route appears or leaves. Withdrawing the old registration and making a new one cannot express that: a candidate set the registry refuses — a profile keyed `deepseek-official`, which `llm-deepseek` already declares — would leave this plugin's whole directory withdrawn and the Models page empty, silently, because the settings-change callback contains the failure. `registerConfigurableProviders` therefore returns a handle carrying `replace(entries)` with the same validate-the-candidate-set-first atomicity `registerAdapter` has, and the plugin uses it. A refused swap costs a diagnostic; the previous entries keep serving. + +Resolution fails loud and names the route and model at fault: a model the catalog does not describe falls back to the route's own `defaultContextWindow`/`defaultMaxTokens`, so a listing that discloses nothing but ids still yields a serviceable route; a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. Because the built `Provider` is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did. + +The configurable-provider directory is now the installed catalog **joined with** every route the current profiles declare, re-registered when that set changes. Without the join a hand-declared route would have no settings address and no configuration surface could show or edit it. + +### A capability whose only level does nothing is reported unavailable + +pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected. + +`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. + +### Credentials stay outside pi-ai + +pi-ai's `Models` carries its own credential concept — a `CredentialStore` keyed by provider id, with `envApiKeyAuth` resolving `credential.key ?? env(VAR)`. Adopting it would have created a second credential source of truth beside `ctx.credentials` and, worse, reintroduced the ambient fallback the harness deliberately forbids: a named-but-missing `apiKeyEnv` must fail with `MISSING_CREDENTIAL` rather than authenticate with whatever unrelated key the environment holds. + +`ModelsImpl.applyAuth` honours `options.apiKey` as the request's key, but only through a provider that declares an api-key method: `resolveProviderAuth` short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery, returning nothing — and so failing the request with `Provider is not configured` — when the provider has no api-key method at all. The harness therefore resolves the route's key through its own seam, as before, and passes the result as the request's `apiKey`; the collection is constructed with no credential store. + +A route's auth follows from that. A catalog route keeps the installed provider's own `auth`, which preserves provider-native ambient discovery for a profile naming no credential, and keeps it through an `api` override too: which environment a provider reads is a property of the provider, not of the wire format its models speak. The exception is a catalog provider with no api-key method — `openai-codex` authenticates through OAuth alone — where a profile that names a credential also gets the harness method beside the provider's own, because otherwise its configured key would be refused before any request went out. A keyless profile on such a route adds nothing and keeps the honest refusal: this adapter holds no OAuth store to resolve through. A hand-declared route gets a harness-owned `ApiKeyAuth` that reports configured-but-keyless rather than unconfigured, leaving the requirement to the protocol — which is where it lives: pi-ai's OpenAI-compatible implementation still demands a key or an `Authorization` header, and says so itself. + +## Alternatives considered + +- **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports. +- **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations. +- **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working. +- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported. + +- **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it. +- **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity. +- **A runtime dynamic catalog** — `fetchModels` plus `ModelsStore`, refreshed in the background. Rejected for this change: it makes the model list external mutable state needing cache, invalidation, and an offline path, and the product need is a one-shot discovery action whose result the user adopts into `settings.yaml`. That action belongs to the configuration surface and is deferred with it; `settings.yaml` stays the single source of truth for what a route serves. + +## Consequences + +Configuring a provider no longer depends on a pi-ai release. A gateway, a self-hosted server, or a model newer than the pinned catalog is a `settings.yaml` edit, and a stale context window can be corrected in place. The deprecated `/compat` import is gone, so pi-ai deleting it is no longer a breaking event. `defaultMaxTokens` now flows from configuration when a deployment states one, without inventing a cap from catalog metadata. + +What it costs: `settings.yaml` grows for a declared route, because it must state its endpoint, protocol, and model ids. `api` applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's `/models`, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error `finish` chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401. + +## Testing + +`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, an OAuth-only catalog route authenticating with the key its profile names while a keyless one stays unconfigured, a repointed route keeping its catalog auth, and every resolution failure that names a route or model. `tests/catalog.spec.ts` also pins the snapshot and directory contracts: an in-flight request whose route set changes during its credential await still reaches the endpoint it resolved against, the next request picks up the new one, a colliding declared route leaves the directory whole, and a declared route's entry appears and leaves with its profile. `packages/llm/llm/tests/topology.spec.ts` covers `replace` — refusing a candidate another registration owns while keeping the current set, accepting a swap over its own entries, allowing an empty set, and failing after disposal. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md new file mode 100644 index 0000000000..f8dba9900b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -0,0 +1,67 @@ +# Agent Note: pi-ai 路由是被声明的提供方,而不是 catalog 查表 + +Status: implemented + +[English](2026-08-03-pi-ai-declared-provider-catalog.md) | 中文 + +## Problem + +`dsh-llm-pi-ai` 把 pi-ai 包生成的 catalog 当成了可配置范围的边界。路由键必须点名一个已安装提供方(`resolveProfiles` 拒绝其余一切),模型列举原样返回 `getBuiltinModels(provider)`,请求期的模型解析又在同一份 catalog 里查这个 id、且只覆盖 `baseURL`。由此产生三个后果,而且三个都是死路而非缺口:OpenAI 兼容网关、自建服务,或比已安装 catalog 更新的提供方,根本无法配置;catalog 尚未跟上的模型即便端点正确也会以 `UNKNOWN_MODEL` 失败;模型的上下文窗口与输出上限完全由锁定的 pi-ai 版本决定,部署既无法更正过期值,也无法为 pi-ai 从未描述过的模型补上。要动其中任何一条,只能升级依赖。 + +适配器还经 `@earendil-works/pi-ai/compat` 的 `streamSimple` 发起流式请求,而该入口自己的模块文档声明它是临时兼容面——其 catalog 读取标了 `@deprecated`,并会在 pi-ai 完成 `ModelManager` 迁移时被删除。这三条配置限制与这个废弃依赖的解法是同一个,因为 pi-ai 受支持的运行时(`createModels()` / `createProvider()`)正是围绕「提供方是被*声明*出来的,而非查出来的」建立的。 + +## Decision + +提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: + +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 +- `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。 +- `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。 +- 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 + +### 快照,而不是共享集合 + +`Models.streamSimple()` 惰性解析 provider——在返回的流首次被消费时,而那已在适配器 await 路由凭据之后。因此就地改动的单一集合,会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider,尽管 `llm.prepareCall()` 早已冻结了该步的 config 并捕获了其适配器注册。配置变化改为构造**新**集合,正在被使用的那个原封不动,于是 seam 的每步冻结得以贯通到底:回复途中切换模型在下一步生效,绝不影响在途的那一步。 + +### 目录原子替换 + +可配置提供方目录跟随 profiles,因此每当一条声明路由出现或离开它都会变化。「撤销旧注册再新建一个」表达不了这件事:注册表拒绝的候选集合——比如一份键为 `deepseek-official` 的 profile,而 `llm-deepseek` 已声明了它——会让本插件的整个目录被撤走、Models 页变空,而且是静默的,因为 settings 变更回调把失败容住了。因此 `registerConfigurableProviders` 改为返回带 `replace(entries)` 的句柄,其「候选集先整体校验」的原子性与 `registerAdapter` 相同,插件改用它。被拒的替换只付出一条诊断;先前的条目继续服务。 + +解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型会回落到该路由自己的 `defaultContextWindow`/`defaultMaxTokens`,因此只公布 id 的列表也能得到可服务的路由;catalog 未提供的路由需要 `api`、`baseURL` 和非空的 `models` 列表。由于构造出的 `Provider` 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。 + +可配置提供方目录现在是已安装 catalog **与**当前 profile 声明的每条路由的并集,并在该集合变化时重新登记。没有这个并集,手工声明的路由就没有 settings 地址,任何配置界面都无法展示或编辑它。 + +### 唯一档位什么也做不到的能力,报告为不可用 + +pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。 + +因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 + +### 凭据留在 pi-ai 之外 + +pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `CredentialStore`,配合 `envApiKeyAuth` 解析 `credential.key ?? env(VAR)`。采用它会在 `ctx.credentials` 之外制造第二个凭据事实源,更糟的是会把 harness 明确禁止的环境回落重新引进来:点名了却取不到的 `apiKeyEnv` 必须以 `MISSING_CREDENTIAL` 失败,而不是用环境里恰好持有的某个无关密钥完成认证。 + +`ModelsImpl.applyAuth` 会把 `options.apiKey` 当作该请求的密钥,但这条路必须经由一个声明了 api-key 方法的提供方:`resolveProviderAuth` 在覆盖存在时短路到该方法,否则依次落到凭据存储与环境发现;若提供方压根没有 api-key 方法,它返回空,请求随即以 `Provider is not configured` 失败。因此 harness 一如既往经自身 seam 解析路由密钥,并把结果作为请求的 `apiKey` 传入;该集合构造时不带任何凭据存储。 + +路由的 auth 由此推出。catalog 路由保留已安装提供方自己的 `auth`,从而为不点名凭据的 profile 保住其提供方原生环境发现,且在 `api` 覆盖之下同样保留:提供方读哪个环境是提供方自身的属性,而非其模型所讲协议格式的属性。例外是没有 api-key 方法的 catalog 提供方——`openai-codex` 只走 OAuth——此时点名了凭据的 profile 会在提供方原有 auth 之外再获得 harness 的方法,否则它配置的密钥会在任何请求发出之前被拒。这类路由上不点名凭据的 profile 什么也不加、并保留那句诚实的拒绝:本适配器没有可供解析的 OAuth 存储。手工声明的路由则获得一个 harness 自有的 `ApiKeyAuth`,它报告「已配置但无密钥」而非「未配置」,把该要求留给协议——那才是它真正所在的位置:pi-ai 的 OpenAI 兼容实现仍要求密钥或 `Authorization` 标头,并且会自己说出来。 + +## Alternatives considered + +- **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。 +- **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。 +- **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。 +- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。 + +- **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。 +- **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。 +- **运行时动态 catalog**——`fetchModels` 加 `ModelsStore`,后台刷新。本次变更拒绝:它把模型列表变成需要缓存、失效与离线路径的外部可变状态,而产品需求是一次性的发现动作、其结果由用户采纳进 `settings.yaml`。该动作属于配置界面,与之一并暂缓;`settings.yaml` 始终是「路由服务什么」的唯一事实源。 + +## Consequences + +配置一个提供方不再取决于 pi-ai 的发布节奏。网关、自建服务,或比锁定 catalog 更新的模型,都是一次 `settings.yaml` 编辑,过期的上下文窗口也能就地更正。废弃的 `/compat` 导入已经消失,因此 pi-ai 删除它不再是破坏性事件。`defaultMaxTokens` 现在只在部署明确给出时才自配置流出,不会从 catalog 元数据里发明一个上限。 + +代价是:声明式路由会让 `settings.yaml` 变长,因为它必须自报端点、协议与模型 id。`api` 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 `/models`,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 `finish` 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。 + +## Testing + +`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通、只走 OAuth 的 catalog 路由用 profile 点名的密钥完成认证而无密钥者保持未配置、改指协议的路由保留其 catalog auth,以及每一种点名路由或模型的解析失败。`tests/catalog.spec.ts` 还钉住了快照与目录两项契约:在途请求即便其路由集在 credential await 期间改变,仍抵达它解析时对应的端点;下一个请求取用新配置;冲突的声明路由让目录保持完好;声明路由的条目随其 profile 出现与离开。`packages/llm/llm/tests/topology.spec.ts` 覆盖 `replace`——拒绝他人已拥有的候选同时保住当前集合、接受对自身条目的替换、允许空集合,以及 dispose 之后失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml new file mode 100644 index 0000000000..4c5c87821f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md +2026-08-04-declaring-a-provider-from-the-models-page.md: 53996e488c467e00754837b83b7a33994d4813ee +2026-08-04-declaring-a-provider-from-the-models-page.zh.md: fa61c48492eabf51f3d325078ceffa84ac52d12c diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md new file mode 100644 index 0000000000..53996e488c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md @@ -0,0 +1,43 @@ +# Agent Note: Declaring a provider from the Models page + +Status: implemented + +English | [中文](2026-08-04-declaring-a-provider-from-the-models-page.zh.md) + +## Problem + +The two layers below made a pi-ai route [a declaration](2026-08-03-pi-ai-declared-provider-catalog.md) and gave the host a way to [interrogate a draft endpoint](2026-08-04-draft-provider-endpoint-interrogation.md). Neither reached a person who does not edit YAML: the Models page still offered one API-key field per provider and a fold with a base URL, so adding a gateway meant opening `$DSH_HOME/settings.yaml` and knowing the profile shape, and correcting a stale context window meant the same. The capability existed and the surface did not expose it. + +Two things were missing, and they are not the same shape. Editing an existing route's models is a *field* on a card that already exists. Declaring a route is a *create*: the route id is being chosen, so until it is chosen there is no settings address to edit. + +## Decision + +The model list is a component shared by both flows; the create is its own card. + +`ModelListEditor` edits a profile's `models` array — one row per model with id, display name, context window, and output cap — and owns the fetch action. An empty list means "serve this route's built-in catalog", so a row is only ever added deliberately; clearing an optional field drops it rather than storing a value the schema would reject, and a capacity that is not a positive integer is not stored at all. + +Fetching asks about the endpoint **the form currently shows** — a base URL edited but unsaved, a key typed but unstored — so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end; the adapter's own message appears beside rows that stay editable by hand. + +`CustomProviderCard` declares a route pi-ai does not ship. It is a separate card because the route id is chosen here: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. The three facts a hand-declared route cannot default — endpoint, protocol, and at least one model — gate the create button, so a failure names the field while the user is still looking at it. + +The protocol choices come from the namespace's **own schema**, read through the settings descriptor the page already fetches (`providers.*.api` is a union of the adapter's `supportedProtocols()`). No new wire field, no constant in the client, and no way for the offered choices to drift from the accepted ones. + +## Alternatives considered + +**Declare a provider through `ProviderEditor` with extra fields.** One card instead of two, but the editor is addressed by `settingsPath`, and a route being named has no path yet. Recomputing the path per keystroke would remount the card and discard the draft; deferring it would mean the editor's whole write path no longer described what it was editing. + +**Add a wire field for the protocol list.** Explicit, and the obvious first instinct. But the settings schema already crosses the wire and already contains the union, so a second copy could disagree with the first — and the one the adapter enforces is the schema. + +**Fetch against the stored profile instead of the live form.** No key would leave the form for an unsaved provider. But the flow that needs fetching most is the one where nothing is stored yet, and a form whose endpoint was edited would quietly interrogate the old one. + +**Write adopted candidates straight into the list.** Fewer clicks, but a fetch would then overwrite capacities the user had corrected, and a listing that discloses only ids would replace real numbers with nothing. + +## Consequences + +A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list. + +What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is the one namespace whose profiles describe a whole provider — a `llm-deepseek` route stays a composition fact. Interrogation reaches only OpenAI-compatible endpoints, so a gateway speaking another protocol reports that it cannot be asked and its models are typed in. And the page now holds a key in component state for the duration of a fetch, which is the same exposure `credentials.set` already has and no longer than the card lives. + +## Testing + +`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md new file mode 100644 index 0000000000..fa61c48492 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 在 Models 页上声明一个提供方 + +Status: implemented + +[English](2026-08-04-declaring-a-provider-from-the-models-page.md) | 中文 + +## Problem + +下面两层已经让 pi-ai 路由变成[一份声明](2026-08-03-pi-ai-declared-provider-catalog.md),并给了 host [询问草稿端点](2026-08-04-draft-provider-endpoint-interrogation.md)的能力。但两者都没有抵达不编辑 YAML 的人:Models 页仍然只为每个提供方提供一个 API 密钥输入框和一个装着 API 地址的折叠区,因此接入一个网关意味着打开 `$DSH_HOME/settings.yaml` 并知道 profile 的形状,更正一个过期的上下文窗口也是如此。能力已经存在,界面却没有暴露它。 + +缺的是两件事,而它们的形状并不相同。编辑既有路由的模型,是一张已经存在的卡片上的一个*字段*;声明一条路由则是一次*创建*:路由 id 正在此处被选定,而在选定之前根本没有可编辑的 settings 地址。 + +## Decision + +模型列表是两条流程共用的组件;创建则是它自己的卡片。 + +`ModelListEditor` 编辑 profile 的 `models` 数组——一行一个模型,含 id、显示名称、上下文窗口与输出上限——并持有获取动作。空列表意味着「使用该路由的内置 catalog」,因此每一行都只会被刻意添加;清空某个可选字段会丢弃它,而不是存入一个 schema 会拒绝的值,不是正整数的容量则根本不会被存下。 + +获取会询问表单**当前显示**的端点——已修改但未保存的 API 地址、已键入但未存储的密钥——因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路;适配器自己的消息会出现在各行旁边,而这些行仍可手工编辑。 + +`CustomProviderCard` 声明 pi-ai 未提供的路由。它之所以是独立卡片,正因为路由 id 是在这里选定的:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的三件事——端点、协议、至少一个模型——会门控创建按钮,因此失败会在用户仍看着该字段时点名它。 + +协议选项来自该 namespace **自己的 schema**,经页面本就会获取的 settings 描述符读出(`providers.*.api` 是适配器 `supportedProtocols()` 的一个 union)。没有新增协议字段,客户端里没有常量,提供的选项也无从与被接受的集合发生漂移。 + +## Alternatives considered + +**在 `ProviderEditor` 上加字段来声明提供方。** 两张卡片变一张,但编辑器由 `settingsPath` 寻址,而正在被命名的路由还没有路径。逐次按键重算路径会让卡片重新挂载并丢掉草稿;推迟计算则意味着编辑器的整条写入路径不再描述它正在编辑的东西。 + +**为协议列表新增一个协议字段。** 显式,也是最直觉的第一反应。但 settings schema 本来就会跨越协议层、本来就含有那个 union,因此第二份副本可能与第一份不一致——而适配器强制执行的是 schema 那一份。 + +**针对已存 profile 而非实时表单发起获取。** 对尚未保存的提供方来说,密钥就不会离开表单。但最需要获取的恰恰是「什么都还没存」的那条流程,而端点已修改的表单会悄悄去询问旧地址。 + +**把采纳的候选直接写进列表。** 点击更少,但一次获取就会覆盖用户已更正的容量,而只公布 id 的列表会把真实数字替换成空。 + +## Consequences + +网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表。 + +代价是:只有 pi-ai 路由可以手工声明,因为 `llm-pi-ai` 是唯一一个其 profile 描述整个提供方的 namespace——`llm-deepseek` 路由仍是组合面的事实。询问只覆盖 OpenAI 兼容端点,因此讲其他协议的网关会报告自己无法被询问,其模型需手工键入。另外,页面在一次获取期间会把密钥保存在组件状态里,这与 `credentials.set` 已有的暴露面相同,且不长于卡片的存活时间。 + +## Testing + +`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml new file mode 100644 index 0000000000..ae96598b48 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +2026-08-04-draft-provider-endpoint-interrogation.md: 65545098cd1063c40081481c1ac8f0afdb4fb390 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: cb09042904f4ab1558c0c214d275a934234955ac diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md new file mode 100644 index 0000000000..65545098cd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -0,0 +1,50 @@ +# Agent Note: Interrogating a draft provider endpoint + +Status: implemented + +English | [中文](2026-08-04-draft-provider-endpoint-interrogation.zh.md) + +## Problem + +Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding an OpenAI-compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and most of these endpoints do publish that list at `GET /models`. + +The obvious answer, a dynamic runtime catalog refreshed in the background, was rejected with the layer below it: it makes a route's model list external mutable state needing a cache, an invalidation story, and an offline path, while the product need is narrower. What is needed is a *question asked once*, whose answer the user adopts into `settings.yaml` — so `settings.yaml` remains the only thing deciding what a route serves. + +The awkward part is that the question is about something that does not exist yet. The provider being added has no route, no stored profile, and no stored credential; the endpoint and key are values in a form the user is still typing. Every existing seam operation is keyed by a registered provider route, so none of them can carry this. + +## Decision + +Interrogation is keyed by **settings namespace**, not by provider route: + +- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name. +- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. The one read is the credential of a route the request names: a configuration surface holds a redacted descriptor rather than the stored secret, so the draft's `apiKey` is present only while the user is typing one, and without that read an already-configured route would be interrogated unauthenticated and answer 401. The typed key wins, being the one under test. +- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. +- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. The method is loopback-only for a second reason besides the key: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which is a probe an anonymous LAN caller must not have. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. + +`dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs. + +### Why not pi-ai's own refresh machinery + +pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `ModelsStore`, and the layer below already builds pi-ai `Provider` objects. Routing interrogation through them would have meant constructing a throwaway provider and collection per question, with a store whose entire purpose — persisting a catalog across runs — contradicts the decision that `settings.yaml` owns the catalog. It would also have bought nothing: **no built-in pi-ai provider implements `fetchModels`**, so the HTTP call and its response parsing are this package's code either way. A direct fetch says what is actually happening. The route's stored credential is resolved by the plugin's own per-request resolver, and only on the branch that reaches the network, so a catalog route answers without touching credentials and never fails over one the question did not need. + +## Alternatives considered + +**Key interrogation by provider route.** Symmetric with every other seam operation, and it would let the request omit the endpoint. But the case that motivates the feature — adding a provider — has no route, so the operation would only work for providers already configured, which are the ones that need it least. + +**Put the capability on `LlmAdapter`.** Adapters are reached through a route registration, so this has the same problem, plus it would make an adapter instance answer questions about endpoints it does not serve. + +**Have the host read the stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. Accepting the draft keeps what the user sees and what is asked identical — with the credential as the one exception, because it is the one field a surface is never shown and so can never put in the draft. + +**Interrogate every pi-ai protocol.** Anthropic's listing happens to share OpenAI's envelope, and Google's does not. Supporting the ones that are easy would make coverage arbitrary and, worse, make a wrong guess at a response shape indistinguishable from a provider with no models. A protocol that says it cannot be interrogated sends the user to hand-entry, which is the documented fallback. + +**Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed. + +## Consequences + +A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber. + +What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage is protocol-shaped rather than provider-shaped — an Anthropic-compatible gateway must be filled in by hand even though its listing would parse. And because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately. + +## Testing + +`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/host/apiproxy/tests/api-proxy-config.spec.ts` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md new file mode 100644 index 0000000000..cb09042904 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 询问草稿中的提供方端点 + +Status: implemented + +[English](2026-08-04-draft-provider-endpoint-interrogation.md) | 中文 + +## Problem + +当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.md)之后,要接入一个 OpenAI 兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而这类端点大多在 `GET /models` 上公布了这份列表。 + +显而易见的答案——后台刷新的运行时动态 catalog——已随下层一并被拒绝:它会把路由的模型列表变成需要缓存、失效语义与离线路径的外部可变状态,而产品需求要窄得多。真正需要的是**只问一次**,其答案由用户采纳进 `settings.yaml`——从而让 `settings.yaml` 始终是唯一决定路由服务什么的东西。 + +麻烦之处在于,被问的对象还不存在。正在新增的提供方没有路由、没有已存 profile、也没有已存凭据;端点与密钥都是用户尚在输入的表单值。而现有的每个 seam 操作都以已注册的提供方路由为键,因此没有一个能承载它。 + +## Decision + +询问以 **settings namespace** 为键,而不是提供方路由: + +- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。 +- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。唯一的读取是请求所点名路由的凭据:配置界面拿到的是脱敏描述符而非已存的机密,因此草稿里的 `apiKey` 只在用户正键入时才存在;没有这次读取,已配置好的路由就会被不带认证地询问,只换回一个 401。键入的密钥优先,因为那正是被测试的那一把。 +- `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。 +- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。除密钥之外它被钉在回环还有第二个理由:它让宿主向调用方选定的 URL 发起 GET 并回报结果,这是匿名 LAN 调用者不该拥有的探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 + +`dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。 + +### 为什么不用 pi-ai 自己的 refresh 机制 + +pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 `ModelsStore`,而下层本来就在构造 pi-ai `Provider` 对象。把询问接到它们上面,意味着每问一次就要构造一个用完即弃的 provider 与集合,而那个 store 的全部目的——跨运行持久化 catalog——恰恰与「`settings.yaml` 拥有 catalog」的决定相抵触。而且它什么也换不来:**没有任何一个 pi-ai 内置 provider 实现了 `fetchModels`**,因此 HTTP 调用及其响应解析无论如何都是本包的代码。直接 fetch 才如实说出正在发生的事。路由已存的凭据由本插件自己那套逐请求解析器取出,且只在真正要联网的那条分支上进行,因此 catalog 路由作答时既不触碰凭据,也不会因为一把这次询问根本用不上的密钥而失败。 + +## Alternatives considered + +**以提供方路由为键。** 与其他每个 seam 操作对称,也能让请求省去端点。但催生该功能的场景——新增提供方——没有路由,于是这个操作只对已配置好的提供方可用,而它们恰恰最不需要它。 + +**把能力挂在 `LlmAdapter` 上。** 适配器要经由路由注册才能抵达,因此问题相同;而且这会让一个适配器实例去回答它并不服务的端点的问题。 + +**让 host 读已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有 secret 跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。接受草稿让用户看见的与被询问的保持一致——凭据是唯一的例外,因为它是界面从不被展示、因而永远无法放进草稿的那个字段。 + +**询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「该提供方没有模型」无法区分。一个明说自己无法被询问的协议,会把用户送去手工填写——那正是既定的回退路径。 + +**用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。 + +## Consequences + +接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、除 fiber 外没有生命周期。 + +代价是:协议层多了第三个承载 secret 的载荷,配置面的只写接口从两个方法变成三个。发现能力按协议而非按提供方划分——一个 Anthropic 兼容网关即便其列表能被解析,也仍须手工填写。而且由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。 + +## Testing + +`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/host/apiproxy/tests/api-proxy-config.spec.ts` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml new file mode 100644 index 0000000000..8a4b3606a8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md +2026-08-05-large-session-jsonl-restore-pipeline.md: eab53c683880ef7095233ed8122e532eb5add547 +2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 039e0c193179677b57e742d55f4c7df6bdff852f diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md new file mode 100644 index 0000000000..eab53c6838 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md @@ -0,0 +1,52 @@ +# Agent Note: Large-session JSONL restore pipeline + +Status: implemented + +English | [中文](2026-08-05-large-session-jsonl-restore-pipeline.zh.md) + +## Problem + +Restoring a stored session activates it and materializes its complete authoritative event log before the agent can run. Large JSONL artifacts made that one-time operation pay several avoidable costs: each independent Zstandard frame created and closed a decoder context, decoded plaintext was accumulated and rescanned as whole-log buffers and strings, and freshly parsed events went through generic snapshot and deep-freeze paths designed for borrowed or cyclic values. + +A representative profile contained 61.8 MiB of Zstandard data, 97.1 MiB of plaintext, and 1,307,073 events. The restore path must reduce its CPU and memory cost without weakening checksum validation, committed-region corruption detection, torn-tail recovery, sequence and surface validation, or the session log's immutability. + +## Decision + +Restoration is one ownership-transfer pipeline from the persistence artifact into `Session.fromRestore`. The compressed artifact remains the source buffer, while each decoding and scanning stage consumes the previous stage's output incrementally without retaining a whole-log plaintext or parsed copy; the resulting event array is the only complete decoded representation. + +### Frame decoding + +The structural Zstandard scanner identifies complete frame ranges before decoding. The dedicated first frame is decoded and parsed separately as the session header; subsequent plaintext frames are yielded in order into the JSONL scanner. + +`ZstdFrameDecoder` gives the reader one lifecycle for interchangeable synchronous implementations. The preferred implementation probes the supported Node 22, 24, and 26 stream shape, reuses one private native decoder context and scratch buffer across all complete frames, and closes it once. If that private shape is unavailable, the factory selects a public `zstdDecompressSync` implementation with the same iterator and checksum-error contract. A yielded scratch view is consumed before the iterator advances. + +After approximately 500 ms of accumulated frame work, the asynchronous reader yields at the next frame boundary and observes cancellation before continuing. A single frame remains an indivisible synchronous operation. Complete frames require end-of-frame and checksum validation; only a structurally incomplete final frame uses the existing prefix decoder for recovery. + +### Incremental JSONL scanning + +`SessionLogScanner` searches raw buffers with `Buffer.indexOf(0x0A)` and converts only complete records to UTF-8 for `JSON.parse`. It carries an incomplete record across decoder writes and copies only that fragment because the private decoder may reuse its output buffer. It does not build a whole plaintext buffer or string, a line array, or a second parsed-record array. + +The scanner stops retaining events at the first unparsable row or sequence gap but continues inspecting later complete records. A later `turn/end` proves that the issue lies in the committed region and rejects the log. The Zstandard reader also rejects any unresolved parse, sequence, or partial-record issue after all complete frames; only a structurally torn final frame may contribute a recoverable suffix. Complete records emitted from that torn frame pass through the same scanner and retain the existing repair offset and recovered-event semantics. + +### Restore admission + +Persistence transfers freshly materialized JSON values to `Session.fromRestore`. These values are detached, acyclic trees, and packed chunk rows expand into newly allocated events, so the restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice. + +Borrowed seeds used by ordinary creation and fork paths still take a JSON snapshot and use the generic cycle-safe deep freeze. The specialization therefore changes only durable restoration; it does not weaken acceptance for caller-owned values. + +## Alternatives considered + +- **One asynchronous native operation per frame** — rejected because dispatch and callback overhead dominates logs containing many small durable batches. Cooperative synchronous decoding pays that overhead only at periodic yield boundaries. +- **Process the complete log synchronously without yielding** — rejected because it prevents cancellation and event-loop progress for the full restore duration. Frame-boundary yields retain a bounded observation point without splitting codec operations. +- **Concatenate all plaintext before scanning** — rejected because it retains the compressed input, complete plaintext, whole-log UTF-8 string, line metadata, and parsed rows at the same time, and it rescans a torn-frame prefix. +- **Implement a streaming JSON parser** — rejected because JSONL already provides record boundaries; native newline search plus `JSON.parse` removes the large intermediates without owning another parser or changing JSON semantics. +- **Use a shared `WeakSet` while freezing restored events** — rejected because JSON materialization cannot produce cycles, and the set adds a lookup per object while retaining the complete graph during traversal. +- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and `Session.events` promises immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them. + +## Consequences + +On the representative profile, incremental scanning reduced JSONL scan time from about 598 ms to 397 ms and peak RSS from about 1,494 MiB to 1,060 MiB. Restore admission reduced `Session.fromRestore` from 604–608 ms to about 263 ms, including an `assertSessionEventEnvelope` reduction from about 77 ms to 13 ms. These measurements characterize the optimization input rather than establish runtime limits. + +The fast decoder depends on runtime-probed Node internals, but incompatibility selects the public implementation rather than changing correctness. Cancellation is observed around cooperative frame-boundary yields; the deadline is not a hard wall-clock bound inside one frame. The complete event array remains resident because it is the active session's authoritative log; the pipeline removes duplicate representations rather than paginating that state. + +Tests force both decoder implementations, compare their frame order and corruption behavior, exercise cooperative cancellation and torn-tail recovery, and retain the existing session envelope, surface, and immutability contracts. diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md new file mode 100644 index 0000000000..039e0c1931 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 大型会话 JSONL 恢复流水线 + +Status: implemented + +[English](2026-08-05-large-session-jsonl-restore-pipeline.md) | 中文 + +## 问题 + +恢复已存储会话会激活该会话,并在 agent(智能体)运行前物化完整且权威的事件日志。处理大型 JSONL 产物时,这个一次性操作会产生几项不必要的开销:每个独立 Zstandard 帧都会创建并关闭一个解码上下文;解码后的明文会汇总成整份日志的缓冲区和字符串,再进行重复扫描;刚解析出的事件还会进入面向借用值或循环引用值设计的通用快照与深度冻结路径。 + +一份代表性性能剖析包含 61.8 MiB Zstandard 数据、97.1 MiB 明文和 1,307,073 个事件。恢复路径必须降低 CPU 与内存开销,同时保持校验和验证、已提交区域损坏检测、撕裂尾部恢复、序列与 `surface` 校验,以及会话日志不可变性。 + +## 决策 + +恢复过程是一条从持久化产物进入 `Session.fromRestore` 的所有权转移流水线。压缩产物仍作为源缓冲区驻留,但解码与扫描阶段会增量消费上一阶段的输出,不会保留整份日志的明文或解析副本;最终事件数组是唯一完整的已解码表示。 + +### 帧解码 + +Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解码专用首帧并将其解析为会话头部,后续明文帧则按顺序产出并送入 JSONL 扫描器。 + +`ZstdFrameDecoder` 为可互换的同步实现提供统一生命周期。首选实现会探测受支持 Node 22、24 与 26 的流结构,在所有完整帧之间复用一个私有原生解码上下文和临时缓冲区,最后只关闭一次。如果私有结构不可用,工厂会选择使用公共 `zstdDecompressSync` 的实现,并保持相同的迭代器和校验和错误契约。迭代器产出的临时视图会在进入下一次迭代前被消费。 + +累计帧处理时间约达 500 ms 后,异步读取器会在下一帧边界让出事件循环,并在继续前观察取消信号。单个帧仍是不可分割的同步操作。完整帧必须通过帧结束与校验和验证;只有结构上不完整的最终帧才使用既有前缀解码器进行恢复。 + +### 增量 JSONL 扫描 + +`SessionLogScanner` 使用 `Buffer.indexOf(0x0A)` 在原始缓冲区中查找换行,只把完整记录转换为 UTF-8 并交给 `JSON.parse`。扫描器会跨解码写入保留不完整记录;由于私有解码器可能复用输出缓冲区,它只复制这个片段。扫描过程不会构造整份明文缓冲区或字符串,也不会构造行数组或第二份解析记录数组。 + +扫描器在遇到第一条无法解析的记录或序列缺口后停止保留事件,但会继续检查后续完整记录。后续出现 `turn/end`,说明问题位于已提交区域,系统会拒绝该日志。处理完所有完整帧后,如果仍存在未决的解析错误、序列错误或部分记录,Zstandard 读取器同样会拒绝日志;只有结构上撕裂的最终帧才能提供可恢复后缀。该撕裂帧产出的完整记录会经过同一扫描器,并保持既有修复偏移量与恢复事件语义。 + +### 恢复准入 + +持久化层把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in` 与 `switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并通过显式 `pending` 数组迭代冻结所拥有的对象图,不使用循环跟踪集合。`surface` 校验会记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次。 + +普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照,并使用支持循环检测的通用深度冻结。因此,这项特化仅改变持久恢复,不会放宽调用方所有值的准入要求。 + +## 考虑过的替代方案 + +- **每帧执行一次异步原生操作**:不予采纳,因为对于包含大量小型持久化批次的日志,调度与回调开销占据主要部分。协作式同步解码只在周期性让出边界支付这类开销。 +- **同步处理完整日志且不让出事件循环**:不予采纳,因为整个恢复期间都无法响应取消或推进事件循环。帧边界让出机制无需拆分编解码操作,就能保留有界的观察点。 +- **扫描前拼接全部明文**:不予采纳,因为该方案会同时保留压缩输入、完整明文、整份日志的 UTF-8 字符串、行元数据和解析记录,并会重新扫描撕裂帧前缀。 +- **实现流式 JSON 解析器**:不予采纳,因为 JSONL 已提供记录边界;使用原生换行搜索与 `JSON.parse` 就能移除大型中间结构,无需自行维护另一套解析器或改变 JSON 语义。 +- **冻结恢复事件时共享一个 `WeakSet`**:不予采纳,因为 JSON 物化不可能产生循环引用,而该集合会对每个对象增加一次查找,并在遍历期间保留完整对象图。 +- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 `Session.events` 承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 + +## 后果 + +在代表性性能剖析中,增量扫描将 JSONL 扫描时间从约 598 ms 降至 397 ms,峰值 RSS 从约 1,494 MiB 降至 1,060 MiB。恢复准入将 `Session.fromRestore` 从 604–608 ms 降至约 263 ms,其中 `assertSessionEventEnvelope` 从约 77 ms 降至 13 ms。这些数据用于描述优化输入,不构成运行时上限。 + +快速解码器依赖运行时探测的 Node 内部接口,但接口不兼容时会改用公共实现,不会改变正确性。系统会在协作式帧边界让出点观察取消信号;截止时间并不是单个帧内部严格的挂钟时间上限。完整事件数组仍会驻留内存,因为它是活跃会话的权威日志;该流水线移除的是重复表示,并未对这份状态做分页。 + +测试会强制执行两种解码器实现,比对帧顺序和损坏处理行为,覆盖协作式取消与撕裂尾部恢复,并保留既有会话信封、`surface` 与不可变性契约。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml new file mode 100644 index 0000000000..eed6bee5f0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +2026-08-05-profile-plugin-bundles.md: 11a8ac3d4005371ca9596ba237aaf42a8e770dee +2026-08-05-profile-plugin-bundles.zh.md: 0e9ebf657ccb9d05967d90a935b356acf287a24c diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md new file mode 100644 index 0000000000..11a8ac3d40 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -0,0 +1,33 @@ +# Agent Note: Profile plugin bundles replace the fixed surface overlays + +Status: implemented + +English | [中文](2026-08-05-profile-plugin-bundles.zh.md) + +## Problem + +The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.yml` shipped inside `apps/cli`, three bespoke entry modes (`--config`, `web`, `-p`) each with its own layer stack, and a single global personal overlay (`$DSH_HOME/config.yaml`). There was no way to install an out-of-tree plugin (a TUI, a provider pack) into a shipped surface without editing the repository, and no place where a third-party package could contribute a default composition. + +## Decision + +Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. + +The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh --profile headless "task"` replaces `-p`; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). + +Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). + +Two supporting refactors: the webserver's built-in static dist serving became the single-owner **fallback seat** (`registerFallback`/`applyIndexTaps`), with the SPA server extracted to `@deepseek-ai/dsh-frontend-static` so the web bundle owns its dist as composition, not launcher code; and the personal-overlay machinery of the [dsh CLI personal-config decision](../feature/2026-07-20-dsh-cli-personal-config.md) (`loadPersonalPatches`, `$DSH_HOME/config.yaml`) was retargeted to the per-profile and home-level `cordis.patch.yml` layers (`loadOptionalPatches`, `watchUserPatches` taking a filename), superseding that note's entry modes and file location while keeping its Harness-home root, patch semantics, and fail-loud parsing. + +## Alternatives considered + +- **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan. +- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony. +- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` seam is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. +- **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file. + +## Consequences + +- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile; the repository no longer needs a row for every deployment shape. +- `apps/cli` shrank to argv parsing, profile machinery consumption, and the pnpm forwarder; `AppCLIEntry` and the per-surface boot paths are gone. +- The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly. +- Backends reject nothing old on disk (pre-release stance): `$DSH_HOME/config.yaml` is simply no longer read. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md new file mode 100644 index 0000000000..0e9ebf657c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -0,0 +1,33 @@ +# Agent Note: profile 插件组合包取代固定的表层 overlay + +Status: implemented + +[English](2026-08-05-profile-plugin-bundles.md) | 中文 + +## Problem + +`dsh` 启动器硬编码了自己的组合:`base.cordis.yml` + `web.cordis.yml` 随 `apps/cli` 一起交付,三种各自定制的入口模式(`--config`、`web`、`-p`)各带一套层栈,外加一个全局的个人 overlay(`$DSH_HOME/config.yaml`)。想把树外插件(一个 TUI、一个提供方扩展包)装进已交付的表层,只能修改仓库;第三方包也没有任何位置可以贡献默认组合。 + +## Decision + +一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 + +已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh --profile headless "task"` 取代 `-p`;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 + +解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 + +两项配套重构:webserver 内置的静态 dist 服务改为单一所有者的**回退席位**(`registerFallback`/`applyIndexTaps`),SPA 服务器提取到 `@deepseek-ai/dsh-frontend-static`,使 web 组合包以组合的方式持有自己的 dist,而不是靠启动器代码;[dsh CLI 个人配置决策](../feature/2026-07-20-dsh-cli-personal-config.md)的个人 overlay 机制(`loadPersonalPatches`、`$DSH_HOME/config.yaml`)改为面向逐 profile 与 home 级的 `cordis.patch.yml` 层(`loadOptionalPatches`、接受文件名的 `watchUserPatches`),取代该笔记的各入口模式与文件位置,同时保留其 Harness home 根目录、patch 语义与大声失败的解析。 + +## Alternatives considered + +- **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。 +- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。 +- **在组合包 manifest(元数据清单)中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是启动器 patch 的普通配置行,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` seam 是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 +- **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。 + +## Consequences + +- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装;仓库不再需要为每种部署形态各留一行。 +- `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;`AppCLIEntry` 与各表层专属的启动路径全部移除。 +- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会大声失败。 +- 后端不拒绝磁盘上的任何旧格式(发布前姿态):`$DSH_HOME/config.yaml` 只是不再被读取。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml new file mode 100644 index 0000000000..09e63a6a1c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-session-preparation.md +2026-08-05-session-preparation.md: 69d39f552ed3041403a24b5aefb435e4e721b09c +2026-08-05-session-preparation.zh.md: a0ca27eb63552566c918c299bd5fba976687812c diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md new file mode 100644 index 0000000000..69d39f552e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.md @@ -0,0 +1,68 @@ +# Agent Note: Reusable Session preparation before publication + +Status: implemented + +English | [中文](2026-08-05-session-preparation.zh.md) + +## Problem + +Cold history inspection and Agent resume independently materialized the same persisted session log. For a large compressed log, each operation repeated the full read, decompression, parse, validation, freezing, and Session construction. Pagination could therefore pay the cold-read cost again, while making a history query activate an Agent would couple a read lifecycle to a live Agent with no natural retirement point. + +Fresh creation and persisted resume also reached the same publication boundary through different construction flows. This obscured the invariant that setup must finish against one unpublished Session before that exact Session and its Agent become visible together. + +## Decision + +`SessionPreparation` owns one exact unpublished `Session` until publication or rollback. It is a Session lifecycle object, not an Agent lifecycle or activation object. Fresh creation wraps the result of `SessionStore.prepare()`; persisted resume obtains a preparation from `SessionPersistence.prepare()`. + +The Agent loop consumes both forms through one setup-and-publication pipeline: it acquires the preparation, builds the private Agent context around `preparation.session`, awaits optional setup, publishes that exact Session and Agent, and disposes the preparation on every exit. Publication transfers the live lifecycle to the existing Session and Agent stores; `SessionPreparation` itself owns no Agent behavior. + +This refines the publication boundary from the [Agent lifecycle and ownership decision](2026-06-18-agent-lifecycle-and-ownership-seams.md) without replacing its ownership model. + +## Persisted preparation lifecycle + +A coordinator-backed persistence implementation loads one cold source into a prepared Session. The backend transfers fresh, mutually unaliased metadata and events together with the source-qualified revision that identifies those exact values; the Session restore path validates and freezes the graphs in place instead of cloning them. The coordinator computes interrupted-turn closers and constructs the exact unpublished Session once. Its immutable header and balanced logical event log form the `SessionInspection` borrowed by readers, while the revision remains internal to persistence. + +`inspect(id, signal?)` does not mutate storage. Synthetic closers exist only in the prepared in-memory view, and a torn physical tail remains untouched. Same-id callers share an in-flight cold read. Once ready, the preparation may remain in a per-coordinator LRU whose capacity defaults to five and is configurable by first-party backends. Before reusing a retained source, the coordinator reads that id's current revision; a mismatch evicts a ready source and repeats the cold materialization. A source already committing or reserved for resume remains exclusively owned, so concurrent inspection borrows that immutable view until publication or release. + +`prepare(id, signal?)` exclusively reserves the prepared Session. It confirms the retained revision before committing any torn-tail and interrupted-turn repair, establishes the durable cursor, then returns a disposable preparation. A stale source is discarded and reloaded instead of being repaired or published. A successful repair also discards the pre-repair source and materializes the committed log again before reservation, so a newer revision is never associated with an older event graph. Another same-id preparation waits until the reservation is published or released. Publication accepts only the exact reserved Session and attaches the committed cursor without rebuilding its history. Failed setup or cancellation returns an unchanged unpublished Session to the LRU; mutation or attachment consumes the reservation. + +The legacy `load(id)` API uses the same preparation and repair machinery, then discards its reservation and returns the immutable logical view. It remains a compatibility API, not the history-to-resume reuse path. This lifecycle extends the [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) while preserving the storage and recovery rules owned by the [session persistence decision](2026-06-14-session-persistence.md). + +## History and resume reuse + +History reads use `inspect()`, so repeated pages borrow the same immutable prepared state without activating an Agent. A later resume uses `prepare()` and receives the exact Session retained by inspection; it does not read, decompress, parse, clone, validate, or freeze the complete log again. + +If the durable log changes after inspection, its revision changes. The next history read or resume discards a retained ready Session and materializes the new log, so an old event graph cannot be associated with a newer snapshot revision. A source already claimed by an in-flight resume is not evicted: its exclusive owner keeps it through publication or release, and concurrent history may borrow the same immutable view. + +Cold continuable-subagent access follows the same path. Descriptor authorization first inspects the child, then `ctx.agents.resume()` reserves and publishes the retained Session. This preserves the lifecycle and authorization rules in the [continuable subagent conversation decision](../feature/2026-07-28-continuable-subagent-conversations.md) while removing its duplicate cold read. + +## Boundaries + +- `readFrom()` remains a detached physical-suffix API. It neither creates nor consumes a preparation, synthesizes logical closers, or joins the LRU. +- HMR adoption keeps the live Session authoritative and reads the stored prefix directly. It may truncate a torn physical fragment but never closes the live open turn as interrupted. +- The cache belongs to one persistence coordinator, not a process-global Session map. Live Sessions are owned by the existing stores and never occupy preparation capacity. +- A fresh create never claims a cold persisted preparation with the same id. Persistence collisions continue to reject. +- Third-party persistence implementations retain the abstract `prepare()` fallback through `load()`. They receive the same publication interface but gain exact-object reuse only when they override preparation. +- Revision validation establishes freshness at the reuse and repair-commit points; it does not add cross-process writer exclusion to a backend. Retries converge after the durable log remains unchanged for one read/check round trip, so continuous external writers can delay preparation. + +## Verification + +The shared persistence contract pins non-mutating balanced cold inspection and later repair. `persistence.spec.ts` and `preparations.spec.ts` pin same-id in-flight sharing, exact Session reuse across inspect and prepare, revision-triggered refresh before history and resume, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Backend tests pin that full and lightweight reads use the same revision identity. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown. + +## Alternatives considered + +**Activate an Agent for history reads.** Rejected because pagination would keep query-only Agents live and transfer cache retirement into the Agent lifecycle. + +**Cache only `{ meta, events }`.** Rejected because resume would still reconstruct, validate, freeze, and copy a Session from the cached values. The exact unpublished Session is the reusable unit. + +**Keep a process-global Session map.** Rejected because it would cross backend and runtime ownership boundaries, retain unbounded identities, and duplicate the live Session store. + +**Add a restore transaction or coordinator to the Agent loop.** Rejected because cold reading, repair, reservation, and cursor attachment are persistence and Session concerns. The Agent loop only needs the uniform `SessionPreparation` ownership boundary. + +**Turn `readFrom()` into logical preparation.** Rejected because watermark consumers need a detached physical suffix and, on seek-capable backends, a bounded read. Recovery balancing and whole-Session reuse have different semantics. + +## Consequences + +One cold materialization can serve history pagination, subagent descriptor inspection, and a later resume. Ownership transfer removes redundant restoration clones, while the bounded per-coordinator LRU limits memory and avoids creating live Agents for queries. Create and resume share one publication protocol without merging Agent and Session responsibilities. + +The first cold inspection now pays the complete validation and Session-construction cost and may retain that unpublished Session until eviction. Persistence must coordinate reservation, append, repair, and publication, and callers must treat inspection values as immutable borrowed state. Backends that rely on the default `prepare()` remain correct but do not receive the reuse optimization. diff --git a/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md new file mode 100644 index 0000000000..a0ca27eb63 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-session-preparation.zh.md @@ -0,0 +1,68 @@ +# Agent Note: 发布前可复用的 Session 准备阶段 + +Status: implemented + +[English](2026-08-05-session-preparation.md) | 中文 + +## 问题 + +冷历史检查和 agent(智能体)恢复会分别实体化同一份持久会话日志。对于大型压缩日志,每次操作都会重新完整读取、解压、解析、验证、冻结并构造 Session。因此,历史分页可能反复承担冷读成本;如果改为由历史查询激活 agent,读取生命周期又会与缺少自然退出时机的实时 agent 耦合。 + +新建和持久化恢复也通过不同构造流程抵达相同的发布边界。这使一项关键不变量不够清楚:设置必须基于一个未发布的 Session 完成,之后系统才能同时公开这个精确 Session 及其 agent。 + +## 决策 + +`SessionPreparation` 持有一个精确的未发布 `Session`,直至发布或回滚。它属于 Session 生命周期,不属于 agent 生命周期或激活机制。新建流程包装 `SessionStore.prepare()` 的结果;持久化恢复则从 `SessionPersistence.prepare()` 取得准备对象。 + +agent loop(智能体循环)通过同一条设置与发布流水线消费这两种形式:先取得准备对象,围绕 `preparation.session` 构建私有 agent 上下文,等待可选设置完成,再发布该精确 Session 和 agent,并在所有退出路径上 dispose 准备对象。发布后,实时生命周期由现有 Session 与 agent 存储接管;`SessionPreparation` 本身不负责任何 agent 行为。 + +该机制细化了 [agent 生命周期与所有权决策](2026-06-18-agent-lifecycle-and-ownership-seams.md)中的发布边界,但不替换其所有权模型。 + +## 持久化准备生命周期 + +使用协调器的持久化实现会将一个冷源加载为准备完成的 Session。后端转移新鲜、彼此无别名的元数据和事件,以及标识这些精确值的来源限定 revision;Session 恢复路径直接验证并冻结这些对象图,不再复制。协调器计算中断轮次的 closer,并且只构造一次精确的未发布 Session。其不可变 header 与平衡逻辑事件日志构成读取方借用的 `SessionInspection`,revision 则保留在持久化内部。 + +`inspect(id, signal?)` 不修改存储。合成 closer 只存在于准备完成的内存视图中,撕裂的物理尾部保持不变。同 id 调用方共享进行中的冷读。准备完成后,该对象可以进入每个协调器自己的 LRU;第一方后端可配置容量,默认保留五个。协调器复用保留源之前会读取该 id 的当前 revision;如果不匹配,就淘汰处于就绪阶段的源并重新完成冷实体化。已经进入提交或为恢复而预留的源仍由其所有者独占,因此并发检查会借用该不可变视图,直至发布或释放。 + +`prepare(id, signal?)` 独占预留准备完成的 Session。它先确认保留的 revision,再提交撕裂尾部和中断轮次修复、建立持久游标,最后返回可 dispose 的准备对象。陈旧源会被丢弃并重新读取,不会参与修复或发布。修复成功后也会丢弃修复前的源,并在预留前重新实体化已提交日志,以免把较新的 revision 关联到较旧的事件对象图。同 id 的另一个准备请求会等待当前预留发布或释放。发布只接受精确的预留 Session,并直接附接已提交游标,无需重建历史。设置失败或取消时,未发生变化的未发布 Session 会返回 LRU;发生变更或完成附接后,系统会消费该预留。 + +存量 `load(id)` API 使用相同的准备和修复机制,随后丢弃其预留并返回不可变逻辑视图。它保留为兼容 API,不承担历史到恢复的复用路径。该生命周期扩展了[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.md),同时继续遵循[会话持久化决策](2026-06-14-session-persistence.md)所规定的存储与恢复规则。 + +## 历史与恢复复用 + +历史读取使用 `inspect()`,因此重复分页可以借用同一份不可变准备状态,而不会激活 agent。后续恢复调用 `prepare()`,直接取得检查阶段保留的精确 Session;系统不会再次完整读取、解压、解析、复制、验证或冻结日志。 + +如果持久日志在检查后发生变化,其 revision 也会变化。下一次历史读取或恢复会丢弃保留且处于就绪阶段的 Session,并实体化新日志,因此旧事件对象图不会被关联到较新的快照 revision。已经由进行中恢复操作取得的源不会被淘汰:其独占所有者会持有它直至发布或释放,并发历史读取可以借用同一个不可变视图。 + +冷 continuable subagent 访问沿用同一路径。系统先检查子会话并完成 descriptor 授权,再由 `ctx.agents.resume()` 预留并发布保留的 Session。这样既遵循 [continuable subagent 会话决策](../feature/2026-07-28-continuable-subagent-conversations.md)中的生命周期与授权规则,也消除了重复冷读。 + +## 边界 + +- `readFrom()` 仍是脱离的物理后缀 API。它不会创建或消费准备对象,不会合成逻辑 closer,也不会进入 LRU。 +- HMR(热模块替换)接管继续以实时 Session 为权威,并直接读取已存储前缀。它可以截断撕裂的物理碎片,但绝不把实时开放轮次关闭为中断状态。 +- 缓存属于单个持久化协调器,而不是进程全局 Session map。实时 Session 由现有存储持有,绝不占用准备容量。 +- 新建流程绝不认领相同 id 的冷持久化准备对象。持久化冲突仍会被拒绝。 +- 第三方持久化实现继续获得通过 `load()` 实现的抽象 `prepare()` 回退。它们使用相同发布接口,但只有覆盖准备流程后才能复用精确对象。 +- Revision 校验在复用点和修复提交点建立新鲜性,但不会为后端增加跨进程 writer 排他。持久日志在一次读取与复核往返内保持不变后,重试才能收敛,因此持续的外部写入可能延迟准备。 + +## 验证 + +共享持久化契约覆盖无变更且已配平的冷检查与后续修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、在历史读取与恢复前由 revision 触发刷新、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append,以及只允许发布预留 Session。后端测试覆盖完整读取与轻量读取使用同一 revision 身份。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和拆卸期间从检查到恢复的路径。 + +## 考虑过的替代方案 + +**由历史读取激活 agent。** 不采用,因为分页会使仅用于查询的 agent 长期保持实时状态,并把缓存退出问题转移到 agent 生命周期。 + +**只缓存 `{ meta, events }`。** 不采用,因为恢复仍需从缓存值重新构造、验证、冻结并复制 Session。真正可复用的单元是精确的未发布 Session。 + +**维护进程全局 Session map。** 不采用,因为它会跨越后端和运行时所有权边界,无界保留身份,并与实时 Session 存储重复。 + +**在 agent loop 中增加恢复事务或协调器。** 不采用,因为冷读、修复、预留和游标附接都属于持久化与 Session 职责。agent loop 只需要统一的 `SessionPreparation` 所有权边界。 + +**把 `readFrom()` 改成逻辑准备流程。** 不采用,因为水位消费方需要脱离的物理后缀;对于可寻址后端,还需要限制实际读取范围。恢复平衡与完整 Session 复用具有不同语义。 + +## 后果 + +一次冷实体化可以同时服务历史分页、subagent descriptor 检查和后续恢复。所有权转移去除了恢复阶段的冗余复制;每个协调器的有界 LRU 限制内存占用,也避免查询创建实时 agent。新建和恢复共享同一发布协议,同时保持 agent 与 Session 职责分离。 + +首次冷检查需要承担完整验证与 Session 构造成本,并可能保留该未发布 Session 直至淘汰。持久化层必须协调预留、append、修复和发布;调用方必须把检查结果视为借用的不可变状态。依赖默认 `prepare()` 的后端仍然正确,但无法获得复用优化。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.i18n.yaml new file mode 100644 index 0000000000..43c5433343 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md +2026-08-05-slot-declaration-injection.md: cb15125977c060144553d7cf75e3c2c26fb1b23b +2026-08-05-slot-declaration-injection.zh.md: 385cab875bb445ba1ca324fc9b45363b8daf50b6 diff --git a/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md new file mode 100644 index 0000000000..cb15125977 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md @@ -0,0 +1,45 @@ +# Agent Note: Slot declaration injection and reload lifetimes + +Status: implemented + +English | [中文](2026-08-05-slot-declaration-injection.zh.md) + +## Problem + +Client plugins may contribute to a slot before or after the plugin that declares it. Cordis service injection cannot express this dependency: a service is only an indirect ordering signal, client manifest dependency rows do not sequence activation, and a slot can disappear and return while every related service remains mounted. Registering immediately therefore races an undeclared slot, while waiting on an unrelated service couples independently reloadable features. + +Slot-level hot replacement also requires two independent owners. Removing the declaring plugin must remove every contribution under its child slots; removing a contributing plugin must remove only that plugin's entries. A replacement declaration with the same key is a new lifetime even when disappearance and reappearance batch into one notification. + +## Decision + +`SlotsService.inject(name, callback)` makes the declared slot itself the dependency. The full `SlotMap` key is statically checked; there is no namespace builder, synthetic Cordis service, or slot-specific `Context`. The callback runs immediately when the declaration exists, otherwise waits, and returns either one synchronous disposer or a synchronous iterable of disposers. Iterable effects install transactionally: a later setup failure disposes every earlier yielded effect in reverse order. + +The ledger records a declaration epoch distinct from the slot's ordinary entry version. An epoch changes whenever a child declaration is created or collapsed. Injection remembers the active epoch, disposes its callback effect when that epoch ends, and reruns the callback for a replacement declaration even when the final observed state is continuously declared. Ordinary contribution changes do not restart injection. + +Both sides retain their natural ownership. The injection controller and every contribution run on the contributing plugin's caller `Context`, so disposing that plugin removes its wait and active entries. The slot ledger's existing child-collapse cascade removes entries when the declarer disappears; injection then runs their disposers to release service-layer resources and remains ready for a later declaration. The declaring plugin's `Context` is neither retained as a capability source nor exposed to contributors. + +Dynamic reload code uses an ordinary Cordis plugin fiber as its replacement unit: activate the new module through `ctx.plugin()`, dispose and await the old fiber before mounting its replacement, and let its `slots.inject` and `slots.register` effects leave with that fiber. Renderer subscriptions observe the ledger removal and unmount the component; no slot-owned fiber tree is required. + +## Failure and lifecycle contract + +An injection whose declaration already exists reports callback setup failures synchronously. A callback failure after a delayed declaration first unsubscribes and rolls back its collected effects, then reports the failure outside the slot notification flush so one registrant cannot starve other listeners. Direct `slots.register()` into an undeclared slot continues to throw: injection is explicit and does not weaken load-time validation. + +Disposing an injection is idempotent. It unsubscribes before releasing the active callback effect, preventing teardown-triggered ledger notifications from resurrecting the contribution. Declaration-bound teardown is synchronous with the ledger boundary, so it releases service-layer resources before any subsequent same-tick registration. A waiting injection disposed with its plugin cannot activate later. + +## Alternatives considered + +**Use `ConversationService` or another service as an ordering barrier.** Service presence does not identify the declaration or follow its reload lifetime, and it creates a false package dependency for presentation-only contributors. + +**Bridge each declaration into a `slot:` Cordis service.** This pollutes the service namespace, turns a misspelled dynamic key into a silent service wait, and disguises ledger state as a business capability. Native slot injection provides the same wait without changing Cordis topology. + +**Create a Cordis context or fiber for every slot.** A contributor needs the intersection of its own plugin lifetime and the declaration lifetime, not the declarer's capabilities. A slot-owned context introduces capability inheritance and dual-parent teardown problems without improving ledger ownership. + +**Make `register()` wait implicitly.** Immediate failure on an undeclared target is a valuable configuration check. Explicit injection distinguishes an intentional independently ordered contribution from a broken composition. + +**Judge replacement from `spec(name) !== undefined` alone.** Collapse and redeclaration can batch into one continuously present final state while the old contributions have already been removed. The declaration epoch preserves that boundary. + +## Consequences + +Slot dependencies become auditable at the registration site and follow declaration replacement without package-specific ordering conventions. Dynamic plugin disposal removes rendered entries through existing Cordis effects, while declaration replacement has a stable hook for later slot-level HMR. + +The runtime carries one additional monotonic epoch per touched slot and injection callbacks must return their cleanup. Multi-registration callbacks use iterable effects so setup and teardown remain atomic. The flat dotted-key ledger and the single `register()` composition authority remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md new file mode 100644 index 0000000000..385cab875b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md @@ -0,0 +1,45 @@ +# Agent Note(agent 决策记录):slot 声明注入与重载生命周期 + +Status: implemented + +[English](2026-08-05-slot-declaration-injection.md) | 中文 + +## 问题 + +客户端插件可能在声明某个 slot 的插件之前或之后向该 slot 贡献内容。Cordis 服务注入无法表达这种依赖:服务只能作为间接的顺序信号;客户端 manifest(元数据清单)的依赖项不会规定激活顺序;即使所有相关服务始终挂载,slot 仍可能消失后重新出现。因此,立即注册会与尚未声明的 slot 形成竞态,而等待无关服务则会耦合本可独立重载的功能。 + +slot 级热替换还要求两个相互独立的所有者。移除声明方插件必须移除其子 slot 下的所有贡献;移除贡献方插件只能移除该插件自己的条目。即使消失与重新出现合并在同一次通知中,同一个 key 的替换声明也属于新的生命周期。 + +## 决策 + +`SlotsService.inject(name, callback)` 以已声明的 slot 本身作为依赖。完整的 `SlotMap` key 会经过静态检查;系统不引入命名空间构建器、合成的 Cordis 服务或 slot 专属 `Context`。声明存在时回调同步执行,否则等待;回调返回一个同步 disposer,或由多个 disposer 构成的同步 iterable。iterable effect 的安装具有事务性:后续 setup 失败时,系统会按逆序 dispose(资源释放)之前 yield 的所有 effect。 + +该账本记录独立于 slot 普通条目版本的 declaration epoch(声明代次)。每当子声明创建或折叠时,epoch 都会变化。注入会记住活跃 epoch;该 epoch 结束时,注入会 dispose 其回调 effect;即使最终观测到的状态始终为已声明,也会为替换声明重新执行回调。普通贡献变更不会重启注入。 + +声明方与贡献方各自保留其自然所有权。注入控制器和每项贡献都运行在贡献方插件调用时的 `Context` 上,因此 dispose 该插件会同时移除其等待与活跃条目。slot 账本现有的子项折叠级联会在声明方消失时移除条目;随后,注入会运行其 disposer 以释放服务层资源,并继续等待后续声明。系统既不会将声明方插件的 `Context` 保留为 capability 来源,也不会向贡献方公开它。 + +动态重载代码使用普通 Cordis 插件 fiber 作为替换单元:通过 `ctx.plugin()` 激活新模块;挂载替换模块之前,先 dispose 并等待旧 fiber;该 fiber 的 `slots.inject` 与 `slots.register` effect 会随之退出。renderer 订阅会观察到账本移除并卸载组件;无需建立 slot 自有的 fiber 树。 + +## 失败与生命周期契约 + +如果注入创建时声明已经存在,回调 setup 失败会同步上报。延迟声明出现后发生的回调失败,会先取消订阅并回滚已收集的 effect,再在 slot 通知刷新之外上报,避免一个注册方使其他 listener 得不到执行机会。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常:注入是显式机制,不会削弱加载时验证。 + +对注入执行 dispose 具有幂等性。它会先取消订阅,再释放活跃的回调 effect,避免拆卸触发的账本通知复活该项贡献。声明绑定的 teardown 与账本边界同步,因此会在同一 tick 的任何后续注册之前释放服务层资源。随插件一同 dispose 的待命注入无法在之后激活。 + +## 备选方案 + +**将 `ConversationService` 或其他服务用作顺序屏障。** 服务存在并不能标识相应声明,也不会跟随声明的重载生命周期;只负责呈现的贡献方还会因此产生虚假的包(package)依赖。 + +**将每项声明桥接为 `slot:` Cordis 服务。** 这会污染服务命名空间,使拼错的动态 key 变成静默的服务等待,并把账本状态伪装成业务 capability。原生 slot 注入无需改变 Cordis 拓扑,即可提供同样的等待能力。 + +**为每个 slot 创建 Cordis 上下文或 fiber。** 贡献方需要的是自身插件生命周期与声明生命周期的交集,而不是声明方的 capability。slot 所有的上下文会引入 capability 继承和双父级拆卸问题,却无法改善账本所有权。 + +**让 `register()` 隐式等待。** 对未声明目标立即失败是一项有价值的配置检查。显式注入能够区分有意独立排序的贡献与错误组合。 + +**只根据 `spec(name) !== undefined` 判断替换。** 折叠与重新声明可以合并成一个最终状态始终存在的通知,而旧贡献此时已经被移除。declaration epoch 保留了这条生命周期边界。 + +## 影响 + +slot 依赖可以在注册点审计,并且无需特定于包的顺序约定即可跟随声明替换。动态插件 dispose 会通过既有 Cordis effect 移除已渲染条目,而声明替换则为后续 slot 级 HMR(热模块替换)提供稳定钩子。 + +运行时为每个被访问的 slot 多维护一个单调 epoch,且注入回调必须返回清理操作。多注册回调使用 iterable effect,使 setup 与 teardown 保持原子性。扁平的点分 key 账本和唯一的 `register()` 组合权威保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml new file mode 100644 index 0000000000..b6e58aabc7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md +2026-08-06-agent-event-payload-objects.md: 470c8fb3f9282005829846307778d3d1088c3888 +2026-08-06-agent-event-payload-objects.zh.md: ff201a7c3134c0ef809c9a798d65412541f9f1e7 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md new file mode 100644 index 0000000000..470c8fb3f9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md @@ -0,0 +1,27 @@ +# Agent Note: Agent-scoped events dispatch a single payload object + +Status: implemented + +English | [中文](2026-08-06-agent-event-payload-objects.zh.md) + +## Problem + +Agent-scoped events historically took positional arguments: a leading `agent` subject, event-specific fields, and a trailing `next` for waterfall/serial events. Adding a field or retiring a context type (as with `PreStepContext` and `RequestFailureContext`) rewrote every listener and emitter across packages, and the contract stayed spread across the parameter list instead of one named payload. + +## Decision + +Every agent-scoped event takes exactly one payload object as its first argument. The payload always carries the subject (`agent`), the event's fields, and the cancellation `signal` when the event has one; `next` remains the last argument of waterfall/serial events. The affected events are the twelve `agent/*` events, `agent-loop/config-start-failed` (the only one without a subject), and `goal/changed`. + +`PreStepContext` and `RequestFailureContext` are retired; their fields live directly in the `agent/pre-step` and `agent/request-error` payloads. + +Dispatch is fused: `agentEvents(ctx, agent)` (and the one-shot `emitAgentEvent`) injects the subject so the scope carrier key and the payload's `agent` cannot diverge, and the injected subject wins even over a structurally acceptable payload that happens to carry an `agent` field. `ReactLoopAgent` builds its dispatcher once in the constructor and routes every emit, serial, and waterfall through it, so hot-path dispatches allocate nothing. + +## Alternatives considered + +**Keep positional signatures.** Adding a field or retiring a context type would keep rewriting every listener and emitter, and the contract would stay spread across the parameter list instead of one named payload. + +**Hand-build the subject at each dispatch site.** The loop's intermediate design called `ctx.waterfall(this.carrier, …)` with a manually constructed `{ agent: this, … }` payload; it avoided per-dispatch allocation but duplicated the subject injection and let the scope key and the payload subject diverge. The fused dispatcher is the single injection point for every dispatch mode. + +## Consequences + +Listener signatures name the full payload once, so extending a payload or retiring a context type is a one-shape change across all listeners and emitters. The subject/scope coupling is enforced by the dispatcher for every dispatch mode, and the loop's hot paths stay allocation-free. diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md new file mode 100644 index 0000000000..ff201a7c31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Agent 作用域事件 dispatch 单个 payload 对象 + +Status: implemented + +[English](2026-08-06-agent-event-payload-objects.md) | 中文 + +## 问题 + +Agent 作用域事件历来采用位置参数:开头的 `agent` 主体、事件专属字段,以及末尾用于 waterfall(瀑布式事件)/serial 事件的 `next`。新增字段或退役上下文类型(如 `PreStepContext` 与 `RequestFailureContext`)都会迫使跨包重写每个监听器和 emitter,契约也一直分散在参数列表中,而不是集中在一个具名 payload 中。 + +## 决策 + +每个 agent 作用域事件都将恰好一个 payload 对象作为其第一个参数。payload 始终携带主体(`agent`)、事件的字段,以及事件有取消信号时的取消 `signal`;`next` 仍然是 waterfall/serial 事件的最后一个参数。受影响的事件是十二个 `agent/*` 事件、`agent-loop/config-start-failed`(唯一没有主体的事件)以及 `goal/changed`。 + +`PreStepContext` 与 `RequestFailureContext` 已退役;它们的字段直接存在于 `agent/pre-step` 与 `agent/request-error` 的 payload 中。 + +dispatch 是融合的:`agentEvents(ctx, agent)`(以及一次性 `emitAgentEvent`)注入主体,使作用域载体键与 payload 的 `agent` 不可能分叉;即使某个结构上可接受的 payload 恰好携带 `agent` 字段,注入的主体仍然优先。`ReactLoopAgent` 在构造函数中构建一次 dispatcher,并将每个 emit、serial 和 waterfall 都经由它路由,因此热路径上的 dispatch 不产生任何分配。 + +## 考虑过的替代方案 + +**保留位置签名。** 新增字段或退役上下文类型依旧会重写每个监听器和 emitter,契约也会继续分散在参数列表中,而不是集中在一个具名 payload 中。 + +**在每个 dispatch 位置手工构造主体。** loop 的中间设计调用 `ctx.waterfall(this.carrier, …)`,传入手工构造的 `{ agent: this, … }` payload;它避免了每次 dispatch 的分配,却重复了主体注入,并让作用域键与 payload 主体分叉。融合的 dispatcher 是每种 dispatch 模式的唯一注入点。 + +## 后果 + +监听器签名一次性命名完整 payload,因此扩展 payload 或退役上下文类型,对所有监听器和 emitter 都是一次形状变更。主体/作用域耦合由 dispatcher 在每种 dispatch 模式下强制执行,且 loop 的热路径保持零分配。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml new file mode 100644 index 0000000000..0168528557 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +2026-08-06-subagent-list-identity-projection.md: ba023d04805ff3335c8f243510aa8ddc15fe13d6 +2026-08-06-subagent-list-identity-projection.zh.md: 368a70f5b3e5a27e4e1c648e8819476d40a57709 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md new file mode 100644 index 0000000000..ba023d0480 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -0,0 +1,184 @@ +# Agent Note: Subagent list identity via the projection unit + +Status: implemented + +English | [中文](2026-08-06-subagent-list-identity-projection.zh.md) + +## Problem + +Before the rewrite, `SubagentService.listChildren` ran two full-log materializations — `listEvents` plus `readEvent` — on every listing for each direct child with `header.origin === 'subagent'`, each materialization accompanied by a full-log structuredClone, all to fold two fields, mode and label, out of the descriptor event. The descriptor's position in the log is not fixed — the fork prefix is arbitrarily long, and zstd-compressed frames carry no seq index — so there is no shortcut to locating it; this path had no cache whatsoever, and its cost amplifies with transcript length × child count × listing frequency. It also dragged session-query in as a hard dependency of listing: in a deployment without a query backend, `list_agents` rejects wholesale with `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`, even though enumeration needs nothing but header facts. + +The same root cause has a second symptom: on every Agent-bound RPC's owner check, the host-side `hasSubagentDescriptor()` scans the target session's own suffix, even though `SessionHeader.origin` already answers the vast majority of the same question. + +The root cause is that the [durable-subagent-catalog decision](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) made the descriptor event (`subagent/descriptor`) the catalog's sole durable authority yet paired descriptor reads with no cache layer, and explicitly accepted the per-child double read as the "no-index correctness baseline". [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md) (#1569) already put "is this a subagent" into the header (`SessionHeader.origin`), so identity determination no longer reads the log; mode and label still had to be scanned. + +## Decision + +mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served identity that passes the seq gate is final; otherwise it pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache of its own, no write-back. + +There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was once this note's settled direction and was under construction for a time, then retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered. + +Key points: + +- **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual. +- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when a non-null `subagent` identity passing the seq gate (`seq >= seedLength ?? 0`) is among its values; otherwise it pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache of its own, no write-back, no index. +- **The `subagent` projection unit is the sole authority over the fold rules**: the live snapshot, the cold restore, and GUI history's detached fold all compute through the registry; no second copy of descriptor-interpretation logic exists. +- **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration. + +Relationship to existing notes: + +- This note supersedes two designs on the list read path in [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md): enumeration through `sessionQuery.traceSession`, and per-child descriptor-event reads (the `listEvents`-plus-exact-`readEvent` double read with in-place diagnostic classification). The diagnostic row semantics is retained, with classification now derived by the list from projection-value absence and activity; the descriptor event remains the sole durable authority for mode/label and the fold input, and the resume authorization and Activation contracts are untouched. This is partial supersession; the two notes stay cross-linked. +- The [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)'s registry contract (`ProjectionDefinition`, `snapshot`, `restore`) is untouched; this note only adds one registration to it — the `subagent` identity unit — and becomes another consumer instance of the two existing reads, snapshot (live) and restore (cold) — GUI history's cold read is already the same shape. The fold rules are registered with the registry exactly once; every consuming surface computes through the registry, and no second copy of the fold logic exists. + +### `subagent` projection unit + +It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent`: + +```ts ignore-check +export type SubagentIdentityProjection = + | { mode: 'one-shot'; label?: string; seq: number } + | { mode: 'continuable'; label: string; seq: number } + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + subagent: SubagentIdentityProjection | null + } +} +``` + +- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is a **serializable null sentinel**: the map entry is `SubagentIdentityProjection | null`, non-optional, never undefined or an absent key. The reason: the registry's onChanged push goes through JSON serialization, where an undefined field is dropped by stringify, the client's frame validation rejects the frame, and a consumer's stored old identity would never update; null passes frames intact, and consumers replace the old identity with the sentinel. The judging discipline: consuming surfaces treat null and undefined (which only a JSON boundary dropping the key can produce) alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). +- Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; the mode/label discriminant matches the child row's strong contract below exactly (the row carries no `seq` — it is the projection's internal own-suffix proof). +- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The state gaining `seq` bumps the unit's `stateVersion` to 2, and existing checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold. +- Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up. + +### Enumeration: subagent-owned live-preferred merge + +`listChildren`'s ([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts)) enumeration goes through no query service: the two sources `ctx.sessions.list()` and `ctx.get('sessionPersistence')?.list()` merge by id, with a live record overriding the same-id persisted record wholesale and no header consistency check. Everything enumeration needs is header facts: + +- Filtering: `header.origin === 'subagent' && header.parentSession === parentSessionId`. +- `hasChildren`: the same merged material, looked at one level down — a direct descendant exists with `origin === 'subagent'` whose `parentSession` is that child. +- `activity`: a live record is `running`; one present only in persistence is `inactive`. +- Ordering: `createdAt` ascending, then child id ascending (matching the old contract). +- **Absent persistence degrades to live-only enumeration, not an error**: in a deployment without persistence, a cold child could not be resumed anyway, and listing live children remains meaningful. (Contrast: the old implementation rejected wholesale when sessionQuery was missing.) +- A persistence listing failure fails the whole enumeration; per-child isolation applies only to the per-child cold reads. + +### Value retrieval: the three-rung compute-and-discard ladder + +For each enumerated child, mode/label retrieval walks a three-rung ladder — compute-and-discard, no cache of its own, no write-back (the third rung is the same shape as apiproxy `session.history`'s cold read): + +| Rung | Read | Cost | +| --- | --- | --- | +| 1: live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | +| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly only when a non-null `subagent` identity satisfies `identity.seq >= header.seedLength ?? 0` — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermark | Zero log reads | +| 3: cold child, fallback | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing | + +- Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. The session store gets the same posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. The two codes map differently on the wire: apiproxy gives only `PROJECTIONS_UNAVAILABLE` a dedicated wire face, and `SESSION_STORE_UNAVAILABLE` goes through the generic internal fallback — the apiproxy composition injects `sessions` itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. +- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. A count/interval checkpoint inside the creation window can land a fork seed's replayed ancestor identity in the row — the ancestor's seq falls inside the seed range, the seq gate rejects it, and it likewise falls to the third rung's verdict. +- Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping). +- The cold path's lifecycle witness: preparation's result must still point at the lifecycle that was enumerated — the witness field set is the same seven fields as the old SOURCE_CONFLICT check (version, id, createdAt, cwd, parentSession, seedLength, delegationDepth); a session deleted and republished under the same id degrades to a `corrupt` row in the old parent's catalog, leaking nothing of the new owner's child. +- Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field. +- The cold-read cost, recorded honestly: only with the cache unmounted or missed does a cold child pay one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout. +- Cancellation: the caller's signal is checked before and after each persistence read, and a read that settles only after abort is rejected, normalized to the stable error code `CANCELLED`. + +### Authority model + +- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints of its own, no in-process memo; the `sessionProjectionCache` checkpoint the second rung reads is an existing composition item's derived data, which this design only reads and never writes. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read (an own descriptor is immutable once appended — a cached identity past the seq gate has no staleness problem; the gate guards against seed-replayed ancestor identities). +- The Session and persistence write paths are entirely unaware of listing and projection consumption: no event-listener write-back, no fold-on-write. +- Enumeration and value retrieval constitute no second authorization source and make no unpublished child visible — the two sources see only published live records and durably written persisted records, consistent with the rule the durable-subagent-catalog note laid down for derived read surfaces. + +### `listChildren` row shape and consuming surfaces + +The `SubagentListEntry` **data structure is identical to before the rewrite** — the child and diagnostic arms, the `kind` discriminant, the three-valued `reason`, and the child arm's strong mode/label contract are all retained; the only change is the diagnostics' information source: the projection system has no failure channel, so diagnostics are derived by the list from projection-value absence and activity, and the list itself parses zero events. The "no value means await the hard read" rule guarantees the ladder always computes mode/label for healthy data. + +```ts ignore-check +export type SubagentListEntry = + | ({ + readonly kind: 'child' + readonly id: SessionId + readonly activity: 'running' | 'inactive' + readonly hasChildren: boolean + } & ( + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + )) + | { + readonly kind: 'diagnostic' + readonly id: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } +``` + +For each enumerated child, the ladder's result maps to a row through four states: + +| Ladder result | Row | +| --- | --- | +| Snapshot carries a non-null `subagent` identity | child row | +| Snapshot present, `subagent` null sentinel or key absent, and the child is **inactive** | diagnostic row, reason `corrupt` (settled debris: a missing, corrupt, or unrecognized-version descriptor, no longer subdivided) | +| Snapshot present, `subagent` null sentinel or key absent, and the child is **running** | no row (creation window: the descriptor is not yet appended — the same window the old implementation omitted) | +| The cold full read fails | diagnostic row, reason `unavailable` | + +- `unsupported` is no longer produced: the type and the wire enum retain the member under "data structures stay as they are", and this note records it as no longer produced. +- Descriptor-less settled debris moves from the old implementation's omit into the `corrupt` diagnostic — damaged, dead child sessions in the corpus are visible rather than silently vanishing, which is exactly the original motivation for keeping diagnostics. +- Any registered unit whose fold/schema throws on this child's log is likewise contained as that child's diagnostic row, reason `corrupt` — a deterministic data fault, aligned with the old implementation's `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` mapping semantics; live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold throw means "the data is bad" — a poisoned running child also gets a `corrupt` row rather than an omit. + +Known boundary deviations (deliberately accepted, recorded with this note): + +- A fork child that died in its publication window, with an ancestor descriptor in its seed, gets the ancestor identity from last-wins and wrongly surfaces as a child row; resume still fails against the own-suffix fold authority (`NOT_RESUMABLE`). The old implementation omitted it via `seedLength` filtering; the projection unit cannot see the header, and this debris-grade deviation is accepted (`subagentTiming` has the same kind of pre-existing exposure). +- Multiple descriptors in the own suffix: the old implementation judged corrupt; last-wins now takes the final one (the provider contract guarantees exactly one anyway). +- A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row. +- A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). +- An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. +- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live snapshot, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine. + +Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm). + +### Change footprint + +| Area | Files | Change | +| --- | --- | --- | +| subagent | projection.ts, projection-types.ts, index.ts | New `subagent` unit and its registration | +| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) | +| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms retired with it | +| tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged | +| wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | Types, row shape, and diagnostic handling **unchanged**; api/subagents.ts only reworded the `history` JSDoc to the dual arm | +| core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | — | **Zero changes** | + +## Alternatives considered + +**mode/label into SessionHeader.** The strongest zero-read guarantee — rows form from the header alone. But a header shape change propagates into both persistence backends and the header compatibility check; SQLite rejects pre-existing data outright, and JSONL pre-existing data can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one `inspect` computation on first listing", touching no durable format. + +**The projection-cache ladder (v3 draft: `cachedSnapshot ?? coldSnapshot` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent. + +**A bounded-read primitive on persistence to rescue pre-existing data.** Opens a new seam primitive for a one-time problem; superseded by the read-time `inspect` full read — the full read the first time pre-existing data is listed is itself the value retrieval. + +**Optional mode/label on list rows (one v4 draft).** Healthy data is always computable; optionality merely spills garbage-data handling complexity onto every consumer — each consuming surface has to grow filter branches and an unknown display state. The strong contract plus omit-when-uncomputable is cleaner. + +**Deleting diagnostic rows outright (one v5 draft).** Deletion turns corpus-corruption visibility into rows silently vanishing, and wire/tool/GUI would each have to absorb contract and snapshot changes; retention only asks the list side to derive the classification from projection-value absence and activity, at zero cost. That damaged, dead child sessions in the corpus must be visible is the original motivation for diagnostics' existence, and with retention the consuming surfaces stay wholly unchanged. + +**A registry computation failure channel (per-unit fault tolerance plus a supplementary `failures` field).** To report corruption and unrecognized versions to consumers, we once considered having the registry catch unit exceptions and attach a per-key failure state beside the snapshot. Rejected: a failure is not a value and needs no channel — a unit never throws, absence is itself the signal, worst case the computation comes back empty, and how that is presented is the consumer's problem. The discussion of this route left one independent observation behind: the vendored Cordis `emit` ([vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)) catches nothing a listener throws, so with the projection driver hanging off `session/event`, a unit exception would escape along emit — which adds weight to the "a unit never throws" discipline, but fixing emit fault tolerance is outside this note's scope. + +**Values landed with query index preparation (the v4/v5 settled design, built for a time).** Projection values folded into session index rows during the sqlite backend's reconciliation rebuild, for zero log reads in the steady read state; the `projectionsFor` bulk read face, the invalidation reconciliation of row values stored against the `(key → stateVersion)` registration set, and the SCHEMA bump were all actually built. Retired wholesale: the direction was backwards — query infrastructure was forced to learn domain vocabulary (projection columns, registration-set reconciliation) while the sole consumer, the subagent list, is satisfied by read-time computation; with consumers down to zero, this derived persistence has no reason to exist. `SESSION_QUERY_PROJECTIONS_UNAVAILABLE` was deleted along with the read face. + +**Subagent hand-rolled parsing plus an in-process memo plus creation seeding (v6 draft).** To excise the session-query dependency, we once considered the subagent package parsing descriptor events itself, avoiding repeated full reads with an in-process memo, and seeding initial values at creation. Superseded by the v7 ladder: live goes through the `sessionProjections` watermark cache and cold through `registry.restore`, reusing the registry's single fold authority — no second copy of descriptor-interpretation logic appears, and no process-state cache or seeding ordering is introduced. + +**DeepReadonly on the session-query output surface (a read-path overhaul experiment).** Make the public query outputs deeply readonly to pin immutable borrowing at the type level. Rejected on evidence: 3 TS2589 occurrences (excessively deep type instantiation) plus 17 sites of array-position contagion (consumers' array methods and spread sites forced to follow); deep immutability is guaranteed by core/session's runtime deep freeze, and that read-path overhaul is not part of this note. + +## Verification + +`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Second-rung cases: an own-seq identity used directly with zero `inspect`, a fork seed's ancestor identity (seq inside the seed range) rejected by the gate and falling through, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold; cold-path lifecycle tampering degrades to `corrupt` field by witness field (`it.each` over the seven). The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, `subagent-diagnostic` (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row. + +## Consequences + +- Listing a live child reads zero log throughout; with the cache unmounted or missed, a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache of its own is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it. +- The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, and loading the `list_agents` plugin no longer requires `sessionQuery`. +- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the registry's and the cache's existing reads (snapshot, cachedSnapshot, restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. +- Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration. +- The diagnostic and enumeration semantics leaves six boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, an unknown parent yielding an empty list instead of not-found, and rung 2's later-event window); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data, the unknown-parent one is a silent query-semantics change, and the rung-2 window is a self-healing cache-serving divergence under the double condition of corruption plus a crash; resume authorization is unaffected throughout, all explicitly accepted. +- Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. + +## Related + +- [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while the list's enumeration and value retrieval move to the subagent-owned merge plus the projection ladder. +- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit to it and becomes a consumer instance of the two existing reads, snapshot and restore. +- [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md) — the origin of `SessionHeader.origin` (#1569), the first half of taking identity determination off the log; its history cold read (inspect prefix plus registry fold) is the same-shape precedent for this note's value ladder. +- [Reusable Session preparation before publication](2026-08-05-session-preparation.md) — the `inspect()` cold read and LRU reuse; the cold child's full-read cost model builds on it. diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md new file mode 100644 index 0000000000..368a70f5b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -0,0 +1,184 @@ +# Agent Note: subagent 列表经投影单元读取身份 + +Status: implemented + +[English](2026-08-06-subagent-list-identity-projection.md) | 中文 + +## 问题 + +重写前的 `SubagentService.listChildren` 对每个 `header.origin === 'subagent'` 的直接 child,每次列表都执行 `listEvents` 加 `readEvent` 两次整日志物化,且每次物化都伴随整日志 structuredClone,只为从描述符事件里折出 mode 与 label 两个字段。描述符在日志中的位置不固定——fork 前缀任意长,zstd 压缩帧没有 seq 索引——因此定位没有捷径;这条路径没有任何缓存,代价随 transcript 长度 × child 数量 × 列表频率放大。它还把 session-query 拉成列表的硬依赖:没有 query backend 的部署,`list_agents` 以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 整体拒绝,尽管枚举所需只是 header 事实。 + +同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()` 在每次 Agent 绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。 + +根因在于 [durable-subagent-catalog 决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为"无索引的正确性基线"。[web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)(#1569)已把"是不是 subagent"放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 + +## 决策 + +mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、不自建缓存、无回写。 + +消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。"值随查询索引落库"曾是本记录的定稿方向并一度施工,最终整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被"算完即止"显式接受。前两条与退役理由详见考虑过的替代方案一节。 + +要点: + +- **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 +- **取值三级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 且过 seq 门(`seq >= seedLength ?? 0`)的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。 +- **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。 +- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 + +与既有记录的关系: + +- 本记录取代 [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活契约不动。属部分取代,两记录保持交叉链接。 +- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) 的 registry 契约(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。 + +### `subagent` projection unit + +挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`: + +```ts ignore-check +export type SubagentIdentityProjection = + | { mode: 'one-shot'; label?: string; seq: number } + | { mode: 'continuable'; label: string; seq: number } + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + subagent: SubagentIdentityProjection | null + } +} +``` + +- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:registry 的 onChanged 推送经 JSON 序列化,undefined 字段被 stringify 丢弃,客户端帧校验拒收,消费方存储的旧身份将永不更新;null 完好过帧,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 +- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强契约完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。 +- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。state 增 `seq` 使 unit `stateVersion` 升至 2,既存 checkpoint 行按 registry 契约版本失配失效、落权威重折。 +- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。 + +### 枚举:subagent 自管 live-preferred 合并 + +`listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))的枚举不经任何查询服务:`ctx.sessions.list()` 与 `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 记录整条覆盖同 id 持久化记录、不做 header 一致性校验。枚举所需全部是 header 事实: + +- 过滤:`header.origin === 'subagent' && header.parentSession === parentSessionId`。 +- `hasChildren`:同一份合并材料向下看一层——存在 `origin === 'subagent'` 且 `parentSession` 为该 child 的直接后代。 +- `activity`:live 记录为 `running`,仅存在于持久化的为 `inactive`。 +- 排序:`createdAt` 升序、再按 child id 升序(与旧契约一致)。 +- **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。) +- persistence 列表失败使整次枚举失败;per-child 隔离只作用于逐 child 的冷读。 + +### 取值:三级"算完即止"阶梯 + +对每个枚举出的 child,mode/label 取值走三级阶梯——算完即止,不自建缓存、无回写(第三级与 apiproxy `session.history` 的冷读同款): + +| 级 | 读法 | 成本 | +| --- | --- | --- | +| 1:live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 | +| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份且 `identity.seq >= header.seedLength ?? 0` 才直接用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 | +| 3:cold child,兜底 | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 | + +- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。两码的 wire 映射有别:apiproxy 只为 `PROJECTIONS_UNAVAILABLE` 设专门 wire 脸,`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 +- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。 +- per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。 +- 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth);同 id 删除后重新发布的会话对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。 +- 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。 +- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案"算完即止",不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 +- 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。 + +### 权威模型 + +- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(own descriptor 一经追加不可变——缓存身份过 seq 门后无陈旧性问题,门防的是种子回放的祖先身份)。 +- Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。 +- 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。 + +### `listChildren` 行形状与消费面 + +`SubagentListEntry` **数据结构与重写前完全一致**——child 与 diagnostic 两臂、`kind` 判别、reason 三值、child 臂的 mode/label 强契约全部保留;变化只在诊断的信息来源:投影体系没有失败通道,diagnostic 由列表按投影值缺席与 activity 派生,列表本身零事件解析。"没有就等待硬读取"保证阶梯对健康数据必然算得出 mode/label。 + +```ts ignore-check +export type SubagentListEntry = + | ({ + readonly kind: 'child' + readonly id: SessionId + readonly activity: 'running' | 'inactive' + readonly hasChildren: boolean + } & ( + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + )) + | { + readonly kind: 'diagnostic' + readonly id: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } +``` + +对每个枚举出的 child,阶梯取值结果按四态映射成行: + +| 阶梯取值结果 | 行 | +| --- | --- | +| 快照含非 null 的 `subagent` 身份 | child 行 | +| 快照在、`subagent` 为 null 哨兵或 key 缺席,且 child **inactive** | diagnostic 行,reason `corrupt`(定局残骸:无、损坏或版本不认识的描述符,不再细分) | +| 快照在、`subagent` 为 null 哨兵或 key 缺席,且 child **running** | 行不出现(创建窗口:描述符尚未追加,与旧实现同窗口 omit) | +| cold 整读失败 | diagnostic 行,reason `unavailable` | + +- `unsupported` 不再被产出:类型与 wire 枚举按"数据结构保持现状"留存该成员,本记录留档其为不再产出。 +- descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。 +- 任一注册 unit 的 fold/schema 在该 child 日志上抛错,同样收纳为该 child 的 diagnostic 行,reason `corrupt`——确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义;live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是"尚无数据",fold 抛错是"数据坏了"——running 的中毒 child 也出 `corrupt` 行而非 omit。 + +已知边界偏差(有意接受,随本记录留档): + +- 死于发布窗口的 fork child,seed 里若有祖先描述符,last-wins 会给出祖先身份,误现为 child 行;恢复仍按 own-suffix 折叠权威失败(`NOT_RESUMABLE`)。旧实现靠 `seedLength` 过滤将其 omit;projection unit 看不到 header,接受此残骸级偏差(`subagentTiming` 有同类既有暴露)。 +- own suffix 出现多个描述符,旧实现判 corrupt,现 last-wins 取末者(provider 契约本就保证恰一)。 +- live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 +- 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 +- 未知 parent,旧实现经 session-query 抛 not-found('parent session … was not found');现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 +- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反 establishing provider"恰追加一次"契约,属损坏类数据,与多描述符偏差同族同源;②需"损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)"双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按"cache 永不为权威"总纲归档为接受项。 + +消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。 + +### 改动落点 + +| 区域 | 文件 | 改动 | +| --- | --- | --- | +| subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 | +| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) | +| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役 | +| tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 | +| wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | 类型、行形状与 diagnostic 处理**零改动**;api/subagents.ts 仅 `history` 的 JSDoc 措辞改为双臂 | +| core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** | + +## 考虑过的替代方案 + +**mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是"第一次列表一次 `inspect` 现算",不碰持久格式。 + +**projection-cache 阶梯(v3 稿:`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。 + +**给 persistence 加有界读原语抢救存量。** 为一次性问题新开 seam 原语;被读时 `inspect` 整读取代——存量第一次被列表时的整读就是取值本身。 + +**list 行 mode/label 可选化(v4 一稿)。** 健康数据必然可算;可选化只是把垃圾数据的处理复杂度外溢给全部消费方——每个消费面都要长出过滤分支和 unknown 展示态。强契约加算不出即 omit 更干净。 + +**彻底删除 diagnostic 行(v5 一稿)。** 删除把库损坏的可见性外溢为行静默消失,wire/tool/GUI 反要各自承担契约与快照变更;而保留只需列表侧按投影值缺席与 activity 派生分类,零成本。库里的坏、死子会话必须可见是 diagnostic 存在的原始动机,保留后消费面整体零改动。 + +**registry 计算失败通道(per-unit 容错加 `failures` 附加字段)。** 为把损坏、版本不认识报告给消费方,曾考虑让 registry 捕获 unit 异常并在 snapshot 旁附 per-key 失败态。被否:failure 不是值,也不必是通道——unit 永不抛错,缺席本身就是信号,"大不了算出来没有",如何呈现是消费方要考虑的事。该路线讨论顺带留下一个独立观察:vendor cordis 的 `emit`([vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts))对 listener 抛错零捕获,投影驱动挂在 `session/event` 上时 unit 异常会沿 emit 逃逸——这加重了"unit 永不抛错"纪律的分量,但 emit 容错的修复不属于本记录范围。 + +**值随 query 索引 preparation 落库(v4/v5 定稿,一度施工)。** 投影值在 sqlite backend 的对账重建里折叠落进 session 索引行,读稳态零日志;`projectionsFor` 批量读面、行值随 `(key → stateVersion)` 注册集存储的失效对账与 SCHEMA bump 均已施工过。整体退役:方向反了——查询基础设施被迫认识领域词汇(投影列、注册集对账),而唯一消费方 subagent 列表读时现算即可满足;消费方归零后,这套派生持久化没有存在理由。`SESSION_QUERY_PROJECTIONS_UNAVAILABLE` 随读面一并删除。 + +**subagent 手工 parse 加进程 memo 加创建播种(v6 稿)。** 为摘除 session-query 依赖,曾考虑 subagent 自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被 v7 阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走 `registry.restore`,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。 + +**session-query 输出面 DeepReadonly(读路径改造实验)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);深层不可变由 core/session 的运行时深冻结保证,该读路径改造未纳入本记录。 + +## 验证 + +`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(`it.each`)降级为 `corrupt`。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。 + +## 后果 + +- live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案"算完即止",不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。 +- subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。 +- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是 registry 与 cache 的既有读法(snapshot、cachedSnapshot、restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 +- per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 +- 诊断与枚举语义留下六处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权均不受影响,显式接受。 +- pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 + +## 相关 + +- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 +- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)——registry 契约的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。 +- [web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 +- [发布前可复用的 Session 准备阶段](2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml new file mode 100644 index 0000000000..20d21565b5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md +2026-08-06-web-markdown-incremental-ast-renderer.md: 3599bfcc78dc4eefe5e82f461a469bdba15f3aae +2026-08-06-web-markdown-incremental-ast-renderer.zh.md: 2e00977da58ef29a77c45abcecf0f3bb62737929 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md new file mode 100644 index 0000000000..3599bfcc78 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md @@ -0,0 +1,33 @@ +# Agent Note: Incremental streaming markdown through a direct mdast renderer + +Status: implemented + +English | [中文](2026-08-06-web-markdown-incremental-ast-renderer.zh.md) + +## Problem + +`MarkdownText` re-parsed the whole accumulated reply on every streaming publish: react-markdown's string-only API builds a fresh unified processor per render and runs micromark → mdast → hast → React over the full text, so per-chunk main-thread work grew linearly with the reply and the stream's cumulative cost grew quadratically. The existing mitigations (frame batching, the isolated streaming tail, the plain fence arm) bounded how often and how widely that work ran, never how much text each run re-parsed. Fixing it needs AST-level input — freezing settled blocks and re-parsing only the source tail — which the string-only wrapper structurally cannot express. + +## Decision + +`MarkdownText` renders mdast directly and parses incrementally while streaming: + +- **Grammars** ([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)): `parseGfm` (streaming arm and `extractMarkdownPlainText`) and `parseGfmWithMath` (settled arm) call `mdast-util-from-markdown` with the same micromark extensions the replaced remark plugins wrapped, so block boundaries are identical everywhere. `mathCompatibility` (ex `remarkMathCompatibility`) now exports its micromark extension directly. +- **Incremental parsing** ([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)): CommonMark block parsing is line-based, so appended text reshapes only the parse frontier. `IncrementalMarkdownParser` keeps the trailing two blocks unstable (the last block is the frontier; the second-to-last is safety margin), freezes everything before them, and re-parses only the source tail from the last frozen block's `position.end.offset` — the parser's own offsets, no bespoke source scanning. Each source region parses O(1) times per stream instead of once per chunk; a single giant block (an unclosed fence) degrades to the old full-reparse cost and no worse. Non-append input resets the state under a bumped generation. +- **Rendering** ([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx), [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)): one switch over mdast node types replaces remark-rehype + react-markdown, reproducing the replaced pipeline's DOM byte-for-byte — table alignment as `text-align` styles, tight-list paragraph unwrapping, task-list classes and checkbox spacing, the footnote section (whose in-page anchors the protocol allowlist already reduced to plain text), literal raw HTML, the separator newlines that surface next to literal HTML text, and rehype-katex's three-arm error chain with KaTeX HTML mapped to React through the browser's own `DOMParser` (no wrapper element, so first/last-child margin rules still reach `.katex-display`; React 18 puts the `.katex-mathml` subtree in the HTML namespace exactly as the replaced pipeline did — a pre-existing limitation outside this parity contract, invisible to the visual `.katex-html` arm). Frozen blocks cache their React elements and keep source-offset keys, so crossing the freeze boundary reconciles instead of remounting; `MarkdownText` is memoized. + +The DOM is pinned by `tests/fixtures/markdown-dom`: fixtures recorded from the react-markdown implementation before the swap, which the new renderer must reproduce under a whitespace-normalizing serializer. A fixture diff is a user-visible markdown style change to review, never to re-record for a refactor. `tests/markdown-incremental.spec.tsx` holds the equivalence property — at every appended prefix, chunked at 1/3/7/16 bytes, the live component's DOM equals a fresh mount's — plus freeze-boundary DOM-node identity and reset behavior. + +This reverses the [assistant-markdown note](../feature/2026-07-23-web-assistant-markdown.md)'s rejected alternative ("maintain a custom React walker"): the incremental requirement is new evidence, the walker's security-sensitive branches (URL allowlist, image policy, inert HTML) were already product-owned functions, and the dependency no longer deleted owned code — it blocked the architecture. That note's untrusted-output policy and renderer selection are unchanged. + +## Alternatives considered + +**Keep react-markdown and split the source into per-segment `` instances.** Zero renderer ownership, but each frame parses the tail twice (boundary detection + render), settled math still re-parses everything, hast construction and the per-render processor remain, and blocks remount when crossing the freeze boundary because element trees cannot be cached across instances. + +**Render cached mdast through `mdast-util-to-hast` + `hast-util-to-jsx-runtime`.** Keeps upstream's node mappings for free, but retains the hast intermediate per frame and two new direct dependencies for a pipeline whose mapping surface is small, closed, and now pinned by fixtures. + +**Parse KaTeX output with `hast-util-from-html-isomorphic` (as rehype-katex does).** Pulls a parse5-based HTML parser into the bundle to parse trusted, vocabulary-constrained KaTeX output the browser's `DOMParser` (with the spec's SVG/MathML attribute adjustments) already parses identically. + +## Consequences + +Streaming per-chunk work now tracks the unstable tail instead of the whole reply, and react-markdown, remark-gfm, remark-math, rehype-katex, unified, and the hast chain left the browser bundle (`mdast-util-math` and `micromark-util-sanitize-uri` became direct dependencies; both were already transitive). The package owns ~25 node mappings, their tests, and the KaTeX DOM conversion — priced against the fixture contract that freezes their output. Two behavioral deviations, both healed by the settled full parse at finalize: a reference-style link or footnote whose definition lands on the other side of a freeze boundary renders literally while streaming, and a footnote reference can flash back to literal text when its definition freezes while the referencing block is still unstable. This module and KaTeX conversion assume a browser DOM (`DOMParser`), which the client-only package already did. diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md new file mode 100644 index 0000000000..2e00977da5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 经由直接 mdast 渲染器的增量流式 Markdown + +Status: implemented + +[English](2026-08-06-web-markdown-incremental-ast-renderer.md) | 中文 + +## Problem + +`MarkdownText` 在每次流式发布时都重新解析整个已累积的回复:react-markdown 的纯字符串 API 每次渲染都新建 unified processor,并对全文跑完 micromark → mdast → hast → React,因此每个 chunk 的主线程工作量随回复长度线性增长,整个流的累计成本随之二次增长。既有缓解手段(帧级合并、隔离的流式尾部、围栏 plain 臂)约束的是这份工作跑多频繁、波及多广,从未约束每次重新解析多少文本。修复它需要 AST 级输入——冻结已定型的块、只重新解析源文本尾部——这是纯字符串封装在结构上无法表达的。 + +## Decision + +`MarkdownText` 直接渲染 mdast,并在流式期间增量解析: + +- **语法**([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)):`parseGfm`(流式臂与 `extractMarkdownPlainText`)和 `parseGfmWithMath`(定稿臂)以被替换的 remark 插件所包装的同一组 micromark 扩展调用 `mdast-util-from-markdown`,因此各处块边界完全一致。`mathCompatibility`(原 `remarkMathCompatibility`)现在直接导出其 micromark 扩展。 +- **增量解析**([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)):CommonMark 块解析按行推进,追加文本只会重塑解析前沿。`IncrementalMarkdownParser` 保留末尾两个块不稳定(最后一块是前沿;倒数第二块是安全裕量),冻结其前的所有块,只从最后一个冻结块的 `position.end.offset` 起重新解析源尾部——用的是解析器自己的偏移量,没有任何自制源扫描。每个源区间在整个流中解析 O(1) 次而非每 chunk 一次;单个巨型块(未闭合围栏)退化为旧的全量重解析成本,不会更差。非追加输入在递增的 generation 下重置状态。 +- **渲染**([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx)、[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)):一个对 mdast 节点类型的 switch 取代 remark-rehype + react-markdown,逐字节复刻被替换管线的 DOM——表格对齐渲染为 `text-align` 样式、紧凑列表段落解包、任务列表类名与复选框空格、脚注区(其页内锚点本就被协议白名单降为纯文本)、字面 raw HTML、会与字面 HTML 文本相邻显形的分隔换行,以及 rehype-katex 的三臂容错链,KaTeX HTML 经浏览器自带的 `DOMParser` 映射为 React(无包裹元素,首/末子元素的 margin 规则仍能作用于 `.katex-display`;React 18 会把 `.katex-mathml` 子树放进 HTML 命名空间,与被替换管线完全一致——既有限制,不在本对等性契约范围内,对承担视觉渲染的 `.katex-html` 臂不可见)。冻结块缓存其 React 元素并保持源偏移 key,跨过冻结边界时走 reconcile 而非重挂载;`MarkdownText` 已 memo 化。 + +DOM 由 `tests/fixtures/markdown-dom` 钉死:fixture 录制自替换前的 react-markdown 实现,新渲染器必须在空白规整序列化器下复现。fixture 差异即用户可见的 markdown 样式变更,必须按此评审,绝不能为重构而重录。`tests/markdown-incremental.spec.tsx` 承载等价性性质——以 1/3/7/16 字节分块,在每个追加前缀处,常驻组件的 DOM 都等于全新挂载——外加冻结边界的 DOM 节点同一性与重置行为。 + +这推翻了[助手 Markdown Note](../feature/2026-07-23-web-assistant-markdown.md) 中被否决的备选("维护一个自定义 React walker"):增量需求是当时不存在的新证据,walker 的安全敏感分支(URL 白名单、图片策略、惰性 HTML)本就是产品自有函数,而该依赖不再删减自有代码——它阻塞了架构。该 Note 的不可信输出策略与渲染器选型不变。 + +## Alternatives considered + +**保留 react-markdown,把源文本切成逐段 `` 实例。** 渲染器零自有成本,但每帧对尾部解析两次(边界检测 + 渲染),定稿数学仍要全量重解析,hast 构建与逐渲染 processor 依旧存在,且块跨过冻结边界时会重挂载——元素树无法跨实例缓存。 + +**用 `mdast-util-to-hast` + `hast-util-to-jsx-runtime` 渲染缓存的 mdast。** 白拿上游节点映射,但每帧保留 hast 中间层,并为一个映射面小、封闭、且已被 fixture 钉死的管线引入两个新直接依赖。 + +**用 `hast-util-from-html-isomorphic` 解析 KaTeX 输出(rehype-katex 的做法)。** 为解析可信、词汇受限的 KaTeX 输出把基于 parse5 的 HTML 解析器拉进 bundle,而浏览器自带的 `DOMParser`(带规范的 SVG/MathML 属性调整)解析结果完全相同。 + +## Consequences + +流式的每 chunk 工作量现在跟随不稳定尾部而非整个回复,react-markdown、remark-gfm、remark-math、rehype-katex、unified 及 hast 链退出浏览器 bundle(`mdast-util-math` 与 `micromark-util-sanitize-uri` 成为直接依赖;两者原本就是传递依赖)。包自有约 25 个节点映射、其测试以及 KaTeX DOM 转换——代价由冻结其输出的 fixture 契约对冲。两个行为偏差,均在定稿的全量解析处自愈:定义落在冻结边界另一侧的引用式链接或脚注在流式期间渲染为字面文本;当脚注定义先冻结而引用块仍不稳定时,脚注引用可能闪回字面文本。本模块与 KaTeX 转换假定浏览器 DOM(`DOMParser`),这个 client-only 包本就如此。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml new file mode 100644 index 0000000000..815f5eee75 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md +2026-08-06-web-shell-dist-chunk-layout.md: 1c7b4273dc243685317b149e2fd7fddf2a6c18d1 +2026-08-06-web-shell-dist-chunk-layout.zh.md: 6f4b94e0bd7412e480458e34922273b389aa8892 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md new file mode 100644 index 0000000000..1c7b4273dc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md @@ -0,0 +1,50 @@ +# Agent Note: Web shell dist chunk split and directory layout + +Status: implemented + +English | [中文](2026-08-06-web-shell-dist-chunk-layout.zh.md) + +## Problem + +The apps/web shell previously built into a single ~1.2 MB (minified) index chunk, roughly 80% of it vendor bytes — KaTeX, the boot grammars and the shiki engine, react-dom, the markdown pipeline — fused with all the workspace shell code (about one fifth). Any one-line shell change rehashed the whole chunk, forcing returning clients to redownload everything; `dist/assets/` was a flat single-level spread of 100-plus files (the main chunk, 23 lazy-loaded grammar chunks, 59 KaTeX font faces, and sourcemaps intermixed), impossible to navigate. + +## Decision + +`apps/web/vite.config.ts` splits the shell into two initial chunks via `manualChunks` and sorts the output into directories via naming functions; the entire configuration contains zero regexes — an exact-package-name Set, a filename list, an extension list. + +**Membership** (`VENDOR_PACKAGES`, by exact npm package name): + +- `vendor` = the three heavy rendering families: math (katex), highlight (shiki), markdown (the micromark/mdast parse pipeline — the incremental React renderer above it is workspace code and not part of this). The live membership is `VENDOR_PACKAGES`; the list is the packages workspace code **imports directly**: the remaining private transitive dependencies (the oniguruma family, @shikijs/core, character tables, dozens more) are referenced only by listed members, so rollup's chunk coloring pulls them into vendor automatically; dependencies shared with the index side fall back to index, diluting it by a few KB — not a correctness issue. +- **Every vendor member must be react-free (the boundary invariant)**: rollup folds a module shared between the entry and a manual chunk into the manual chunk — one listed package importing react/jsx-runtime would drag the single shared react copy into vendor, away from index. The React side of markdown/math rendering is workspace code and naturally lives in index, so the whole react family stays pinned to index. +- `index` (the default chunk) = the react family (react, react-dom, scheduler, use-sync-external-store), vendored cordis, all workspace code, and the unlisted small pieces (anser, clsx). +- `@shikijs/langs` is special-cased: the boot grammars (`BOOT_GRAMMAR_FILES`: typescript, shellscript, json — the three that highlight.ts statically imports, all self-contained data modules with zero internal imports) go into vendor; the remaining 23 lazy-loaded grammars get no assignment and each keeps its own on-demand chunk. +- `index.html` is wired up automatically by vite: index loads via `\n" +

+ #text "Paragraph with inline html and bold tag kept literal?" + #text "\n

\nhtml block content\n
\n" +

+ #text "after" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt new file mode 100644 index 0000000000..8d8344f72b --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt @@ -0,0 +1,7 @@ +

+ #text "\n" +

+ #text "Paragraph with inline html and bold tag kept literal?" + #text "\n

\nhtml block content\n
\n" +

+ #text "after" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt new file mode 100644 index 0000000000..5cc4257ab7 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt @@ -0,0 +1,16 @@ +

+

+ #text "A " + + #text "full" + #text " reference, a " + + #text "collapsed" + #text " one, and a " + + #text "shortcut" + #text " one." +

+ #text "[missing full][nope], [missing collapsed][], ![missing image][gone]." +

+ ref image diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt new file mode 100644 index 0000000000..5cc4257ab7 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt @@ -0,0 +1,16 @@ +

+

+ #text "A " + + #text "full" + #text " reference, a " + + #text "collapsed" + #text " one, and a " + + #text "shortcut" + #text " one." +

+ #text "[missing full][nope], [missing collapsed][], ![missing image][gone]." +

+ ref image diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.settled.txt new file mode 100644 index 0000000000..95a943cb4a --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.settled.txt @@ -0,0 +1,8 @@ +

+

+ #text "Streaming" +
    +
  • + #text "first" +
  • + #text "**unfinished" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.streaming.txt new file mode 100644 index 0000000000..95a943cb4a --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.streaming.txt @@ -0,0 +1,8 @@ +
    +

    + #text "Streaming" +
      +
    • + #text "first" +
    • + #text "**unfinished" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt new file mode 100644 index 0000000000..2c2d9d7e0f --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt @@ -0,0 +1,11 @@ +
      +
      + + + +
      + #text "a" + + #text "b" +

      + #text "after" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt new file mode 100644 index 0000000000..2c2d9d7e0f --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt @@ -0,0 +1,11 @@ +

      +
      + + + +
      + #text "a" + + #text "b" +

      + #text "after" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt new file mode 100644 index 0000000000..6a669ffe2f --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt @@ -0,0 +1,35 @@ +

      +
      + + + + + + +
      + #text "Left" + + #text "Center" + + #text "Right" + + #text "None" +
      + #text "a" + + #text "b" + + #text "c" + + + #text "code" +
      + + #text "link" + + + #text "em" + + #text "1" + + #text "2" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt new file mode 100644 index 0000000000..6a669ffe2f --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt @@ -0,0 +1,35 @@ +
      +
      + + + + + + +
      + #text "Left" + + #text "Center" + + #text "Right" + + #text "None" +
      + #text "a" + + #text "b" + + #text "c" + + + #text "code" +
      + + #text "link" + + + #text "em" + + #text "1" + + #text "2" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt new file mode 100644 index 0000000000..b467974cb4 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt @@ -0,0 +1,19 @@ +
      +
        +
      • + + #text " done with " + + #text "strong" +
      • + + #text " pending" +
      • + #text "plain sibling" +
          +
        1. + + #text " ordered done" +
        2. + + #text " ordered pending" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt new file mode 100644 index 0000000000..b467974cb4 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt @@ -0,0 +1,19 @@ +
          +
            +
          • + + #text " done with " + + #text "strong" +
          • + + #text " pending" +
          • + #text "plain sibling" +
              +
            1. + + #text " ordered done" +
            2. + + #text " ordered pending" diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx new file mode 100644 index 0000000000..c1b4eb2f2f --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx @@ -0,0 +1,265 @@ +// @vitest-environment jsdom +// DOM-parity contract for MarkdownText: every corpus document's rendered DOM +// is pinned as a file snapshot. The fixtures were recorded from the +// react-markdown implementation this renderer replaced; the custom mdast +// renderer must reproduce them byte-for-byte (after whitespace +// normalization), so a fixture diff means a user-visible markdown style +// change and must be reviewed as such — never re-record to silence a +// refactor. +// +// Provenance is reproducible: the replaced pipeline last lived at commit +// 9e8101b800 (origin/master before the renderer swap merged). Checking out +// that ref in a worktree, copying this spec, and running it records all +// fixtures from react-markdown byte-identical to the ones committed here: +// git worktree add /tmp/parity origin/master --detach && cd /tmp/parity +// pnpm install && cp packages/client/ui-primitives/tests/ +// npx vitest run packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx +// diff -r # byte-identical +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +/** + * Serialize rendered DOM deterministically: adjacent text nodes coalesced + * (React renders adjacent string children as separate DOM text nodes while + * hast merges them — invisible either way), whitespace-only runs dropped + * outside `pre` (the markdown pipeline injects cosmetic newlines between + * blocks that HTML rendering collapses), attributes sorted by name, children + * indented for reviewable diffs. + */ +function serialize(node: Node, indent: string, inPre: boolean): string { + if (node.nodeType !== Node.ELEMENT_NODE) return '' + const element = node as Element + const attrs = [...element.attributes] + .map(attr => `${attr.name}=${JSON.stringify(attr.value)}`) + .sort() + .join(' ') + const open = attrs === '' ? element.tagName.toLowerCase() : `${element.tagName.toLowerCase()} ${attrs}` + const nowInPre = inPre || element.tagName === 'PRE' + return `${indent}<${open}>\n${serializeChildren(element, `${indent} `, nowInPre)}` +} + +function serializeChildren(element: Element, indent: string, inPre: boolean): string { + let out = '' + let textRun = '' + const flush = (): void => { + if (textRun !== '' && (inPre || textRun.trim() !== '')) { + out += `${indent}#text ${JSON.stringify(textRun)}\n` + } + textRun = '' + } + for (const child of element.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + textRun += child.textContent ?? '' + continue + } + flush() + out += serialize(child, indent, inPre) + } + flush() + return out +} + +/** Render one markdown source through MarkdownText and serialize the DOM. */ +function renderCase(text: string, streaming: boolean): string { + const { container, unmount } = render() + const out = [...container.childNodes].map(child => serialize(child, '', false)).join('') + unmount() + return out +} + +const CORPUS: Record = { + 'headings-and-paragraphs': [ + '# H1 with `code`', + '', + '## H2', + '', + '### H3', + '', + '#### H4', + '', + '##### H5', + '', + '###### H6', + '', + 'Paragraph one with **strong**, *emphasis*, ~~strike~~, and `inline`.', + '', + 'Setext title', + '=========', + '', + 'Second setext', + '---------', + ].join('\n'), + 'heading-tight-against-list': '#### Small heading\n\n- one\n- two\n\n##### Next\n\n1. a\n2. b', + 'hard-breaks-and-hr': 'two-space break \nafter break\n\nbackslash break\\\nafter backslash\n\n---\n\ntail', + 'blockquote-nested': '> level one\n> still one\n>\n> > nested\n>\n> - quoted list\n\nafter', + 'lists-tight-loose-nested': [ + '- tight one', + '- tight two', + ' - child', + '', + '1. first', + '2. second', + '', + '3. ordered with start', + '4. next', + '', + '- loose item one', + '', + '- loose item two', + '', + ' second paragraph of loose item', + '', + '- item with nested blocks', + '', + ' ```', + ' fenced inside list', + ' ```', + ].join('\n'), + 'task-lists': '- [x] done with **strong**\n- [ ] pending\n- plain sibling\n\n1. [x] ordered done\n2. [ ] ordered pending', + 'table-with-alignment': [ + '| Left | Center | Right | None |', + '| :--- | :---: | ---: | --- |', + '| a | b | c | `code` |', + '| [link](https://example.com) | *em* | 1 | 2 |', + ].join('\n'), + 'code-fences': [ + '```ts', + 'const answer: number = 42', + '```', + '', + '```', + 'no language', + '```', + '', + '```unknown-lang', + 'plain fallback', + '```', + '', + '```ts some=meta', + 'const withMeta = true', + '```', + '', + '```', + '```', + '', + ' indented code block', + ' second line', + ].join('\n'), + 'fence-trailing-blank-lines': [ + '```', + 'kept blank line follows', + '', + '```', + '', + '```ts', + 'const doubled = true', + '', + '', + '```', + '', + 'after', + ].join('\n'), + 'table-header-only': '| a | b |\n| --- | --- |\n\nafter', + 'inline-code-with-newline': 'Spans `a\nb` across a line.', + 'links-and-autolinks': [ + '[https ok](https://example.com "with title") and [mailto ok](mailto:dev@example.com).', + '', + '[relative dropped](/settings) and [js dropped](javascript:alert(1)) and [upper kept](HTTPS://example.com).', + '', + ' and bare autolink https://autolink.example.com literal.', + '', + '[spaces encoded](https://example.com/a b)', + ].join('\n'), + 'images': [ + '![https image](https://example.com/secure.png "img title")', + '', + '![http image](http://example.com/plain.png)', + '', + '![relative dropped](private.png) and inline ![bad scheme](javascript:alert(1)) end.', + '', + '![](https://example.com/empty-alt.png)', + ].join('\n'), + 'reference-links-and-images': [ + 'A [full][ref] reference, a [collapsed][] one, and a [shortcut] one.', + '', + '[missing full][nope], [missing collapsed][], ![missing image][gone].', + '', + '![ref image][imgref]', + '', + '[ref]: https://example.com/ref "ref title"', + '[collapsed]: https://example.com/collapsed', + '[shortcut]: https://example.com/shortcut', + '[imgref]: https://example.com/ref.png', + ].join('\n'), + 'footnotes': [ + 'First use[^a] and reuse[^a] and another[^b].', + '', + '[^a]: Footnote a body with [link](https://example.com).', + '', + '[^b]: Footnote b first paragraph.', + '', + ' Second paragraph of b.', + ].join('\n'), + 'raw-html-dropped': [ + '', + '', + 'Paragraph with inline html and bold tag kept literal?', + '', + '
              ', + 'html block content', + '
              ', + '', + 'after', + ].join('\n'), + 'entities-and-escapes': 'AT&T, 3 < 4, \\*not em\\*, backslash \\\\ literal, © entity.', + 'math-inline-and-display': [ + 'Einstein wrote $E = mc^2$ inline.', + '', + '$$', + '\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p', + '$$', + '', + 'Backslash inline \\(\\frac{1}{5}\\) and display:', + '', + '\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]', + ].join('\n'), + 'math-edge-cases': [ + 'Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$.', + '', + 'Unbalanced errors render the error arm: $\\frac{$', + '', + '| Symbol | Value |', + '| --- | --- |', + '| $\\theta$ | \\(\\frac{1}{5}\\) |', + '', + '```math', + '\\sqrt{2}', + '```', + ].join('\n'), + 'gfm-strikethrough-and-literals': 'Mixed ~~gone~~ text with www.example.com literal and user@example.com email.', + 'cjk-strong-and-inline-code-url': [ + '**注意:**内容在标点后直接闭合。', + '', + '**Notice:**text keeps upstream parsing.', + '', + '*提醒!*单星号也保持上游行为。', + '', + '`https://example.com/preview?q=one%20two#result` 与 `curl http://127.0.0.1:3199/` 以及 `javascript:alert(1)`。', + ].join('\n'), + 'definition-only': '[unused]: https://example.com/unused', + 'streaming-typical-partial': '## Streaming\n\n- first\n- **unfinished', +} + +describe('MarkdownText DOM parity fixtures', () => { + for (const [name, text] of Object.entries(CORPUS)) { + it(`settled: ${name}`, async () => { + await expect(renderCase(text, false)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.settled.txt`) + }) + it(`streaming: ${name}`, async () => { + await expect(renderCase(text, true)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.streaming.txt`) + }) + } +}) diff --git a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx new file mode 100644 index 0000000000..b36ce8c674 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx @@ -0,0 +1,427 @@ +// @vitest-environment jsdom +// Incremental streaming behavior: a MarkdownText kept mounted across +// append-only rerenders must show, at every step, exactly the DOM a fresh +// mount of the same prefix shows, while reusing the frozen blocks' DOM nodes +// instead of remounting them. +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { Root, RootContent } from 'mdast' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts' +import { parseGfm } from '../src/markdown/parse.ts' + +afterEach(cleanup) + +/** + * A many-block document exercising every freeze-sensitive construct. The + * prefix-equivalence property below holds only while no reference or + * footnote definition lands on the far side of a freeze boundary from its + * use: a fresh mount parses everything in one tree while the live stream's + * frozen blocks are already baked (the fingerprint test demonstrates the + * documented deviation). Keep definitions adjacent to their references when + * extending this corpus. + */ +const STREAM_DOC = [ + '# Title', + '', + 'First paragraph with **strong** and `code`.', + '', + '- list item one', + '- list item two', + '', + ' continuation of item two', + '', + 'Setext heading', + '===', + '', + '| a | b |', + '| --- | --- |', + '| 1 | 2 |', + '', + '```ts', + 'const x = 1', + '', + 'still inside the fence', + '```', + '', + '> quote with lazy', + 'continuation line', + '', + 'Uses a footnote[^n] twice[^n].', + '', + '[^n]: The footnote body.', + '', + 'Closing paragraph after enough blocks to freeze everything above.', + '', + 'One more tail block.', +].join('\n') + +describe('incremental streaming rendering', () => { + for (const chunkSize of [1, 3, 7, 16]) { + it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => { + const live = render() + for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) { + const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)) + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + } + live.unmount() + }) + } + + it('keeps frozen block DOM nodes across freezes instead of remounting', () => { + const paragraphs = Array.from({ length: 8 }, (_, i) => `Paragraph number ${i}.`) + const first = `${paragraphs[0]}\n\n` + const live = render() + const firstBlock = live.container.querySelector('p') + expect(firstBlock?.textContent).toBe(paragraphs[0]) + live.rerender() + // Same DOM node instance: the block kept its key across the freeze boundary. + expect(live.container.querySelector('p')).toBe(firstBlock) + expect(live.container.querySelectorAll('p')).toHaveLength(paragraphs.length) + live.unmount() + }) + + it('recovers when the text diverges instead of appending', () => { + const live = render() + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + live.unmount() + fresh.unmount() + }) + + it('drops the streaming cache when the copy labels change identity', () => { + const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n') + const live = render() + expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy']) + live.rerender() + expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren']) + live.unmount() + }) + + it('settles into the full math-enabled render after streaming', () => { + const doc = 'Value $E = mc^2$ inline.\n\nSecond.\n\nThird.\n\nFourth.' + const live = render() + expect(live.container.querySelector('.katex')).toBeNull() + live.rerender() + const settled = render() + expect(live.container.innerHTML).toBe(settled.container.innerHTML) + expect(live.container.querySelector('.katex')).not.toBeNull() + live.unmount() + settled.unmount() + }) +}) + +describe('incremental parsing is actually in effect', () => { + it('hands the grammar only the source tail once blocks freeze', () => { + const calls: string[] = [] + const recording = (text: string): Root => { + calls.push(text) + return parseGfm(text) + } + const parser = new IncrementalMarkdownParser(recording) + const paragraphs = Array.from({ length: 40 }, (_, i) => `Paragraph number ${i} with some words.`) + let text = '' + for (const paragraph of paragraphs) { + text += `${paragraph}\n\n` + parser.update(text) + } + expect(text.length).toBeGreaterThan(1500) + // Warm-up aside, every parse sees only the unstable tail: bounded by a + // few paragraphs, not the growing document. + const steady = calls.slice(5) + expect(Math.max(...steady.map(call => call.length))).toBeLessThan(200) + expect(steady.every(call => !call.includes('Paragraph number 0 '))).toBe(true) + // Cumulative parsed bytes stay linear in the document; full re-parsing + // would have accumulated ~40/2 times the document length here. + const totalParsed = calls.reduce((sum, call) => sum + call.length, 0) + expect(totalParsed).toBeLessThan(text.length * 5) + }) + + it('shows the documented streaming fingerprint: a definition frozen earlier no longer resolves a new reference, and settling heals it', () => { + const doc = [ + '[ref]: https://example.com/target', + '', + 'Paragraph one keeps the definition company.', + '', + 'Paragraph two pushes the freeze boundary.', + '', + 'Paragraph three freezes the definition out.', + '', + 'See [the link][ref] for details.', + ].join('\n') + const head = doc.slice(0, doc.indexOf('See')) + const live = render() + live.rerender() + // The tail re-parse cannot see the frozen definition, so the reference + // stays literal — the direct observable that the whole text was NOT + // re-parsed (a one-shot mount of the same text resolves it). + expect(live.container.querySelector('a')).toBeNull() + expect(live.container.textContent).toContain('[the link][ref]') + const fresh = render() + expect(fresh.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target') + fresh.unmount() + // The settled swap re-parses everything and heals the deviation. + live.rerender() + expect(live.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target') + live.unmount() + }) +}) + +describe('freeze dynamics around frontier-sensitive constructs', () => { + it('an unclosed fence pins the tail: nothing freezes until it closes', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + let text = 'p1.\n\np2.\n\np3.\n\n```ts\n' + const opened = parser.update(text) + const frozenAtOpen = opened.frozen.length + expect(opened.tail[opened.tail.length - 1]?.node.type).toBe('code') + for (const line of ['const a = 1\n', '\n', 'looks like a paragraph\n', '- looks like a list\n']) { + text += line + const grown = parser.update(text) + // The fence swallows everything appended, so the block census cannot + // grow and the freeze boundary must hold still. + expect(grown.frozen.length).toBe(frozenAtOpen) + expect(grown.tail[grown.tail.length - 1]?.node.type).toBe('code') + } + text += '```\n\nafter one.\n\nafter two.\n' + const closed = parser.update(text) + expect(closed.frozen.length).toBeGreaterThan(frozenAtOpen) + const frozenCode = closed.frozen.find(block => block.node.type === 'code')?.node + expect(frozenCode?.type === 'code' && frozenCode.value).toContain('looks like a list') + }) + + it('a list can keep extending across blank lines until it freezes whole', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + let text = 'intro.\n\nsecond.\n\nthird.\n\n- item a\n- item b\n' + const before = parser.update(text) + const frozenBefore = before.frozen.length + text += '\n- item c\n' + const extended = parser.update(text) + expect(extended.frozen.length).toBe(frozenBefore) + const tailList = extended.tail[extended.tail.length - 1]?.node + expect(tailList?.type === 'list' && tailList.children).toHaveLength(3) + text += '\nafter.\n\nmore.\n\nend.\n' + const after = parser.update(text) + const frozenList = after.frozen.find(block => block.node.type === 'list')?.node + expect(frozenList?.type === 'list' && frozenList.children).toHaveLength(3) + }) + + it('keeps every previously frozen key as a stable prefix across the stream', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + let previous: readonly number[] = [] + for (let end = 7; end < STREAM_DOC.length + 7; end += 7) { + const { frozen } = parser.update(STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length))) + const keys = frozen.map(block => block.key) + expect(keys.slice(0, previous.length)).toEqual(previous) + previous = keys + } + expect(previous.length).toBeGreaterThan(4) + }) +}) + +describe('multibyte content', () => { + const CJK_DOC = [ + '# 标题 🎉', + '', + '这是一段包含 **加粗**、`行内代码` 与表情 😀🚀 的中文段落。', + '', + '- 列表项一 ✅', + '- 列表项二', + '', + '> 引用一行,带表情 🐟', + '', + '```', + '中文代码 🎯', + '```', + '', + '| 键 | 值 |', + '| --- | --- |', + '| 甲 | 乙 |', + '', + '结尾段落,足够多的块让前面全部冻结。🌊', + ].join('\n') + + it('code-unit chunking (splitting surrogate pairs mid-stream) matches fresh renders', () => { + const live = render() + for (let end = 1; end < CJK_DOC.length + 1; end += 1) { + const prefix = CJK_DOC.slice(0, Math.min(end, CJK_DOC.length)) + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + } + live.unmount() + }) + + it('freeze-cut offsets agree with one-shot parse offsets on astral content', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + let result = parser.update(CJK_DOC.slice(0, 3)) + for (let end = 6; end < CJK_DOC.length + 3; end += 3) { + result = parser.update(CJK_DOC.slice(0, Math.min(end, CJK_DOC.length))) + } + const oneShot = parseGfm(CJK_DOC).children.map(node => node.position?.start.offset) + expect([...result.frozen, ...result.tail].map(block => block.key)).toEqual(oneShot) + expect(result.frozen.length).toBeGreaterThan(3) + }) +}) + +describe('streaming composition across freezes', () => { + it('continues footnote numbering from frozen references and lists all definitions', () => { + const doc = [ + 'Alpha uses a footnote[^a].', + '', + '[^a]: First note body.', + '', + 'Filler one.', + '', + 'Filler two.', + '', + 'Filler three.', + '', + 'Beta uses another[^b].', + '', + '[^b]: Second note body.', + ].join('\n') + const head = doc.slice(0, doc.indexOf('Beta')) + const live = render() + live.rerender() + expect([...live.container.querySelectorAll('p sup')].map(sup => sup.textContent)).toEqual(['1', '2']) + expect([...live.container.querySelectorAll('section.footnotes li')].map(li => li.id)) + .toEqual(['user-content-fn-a', 'user-content-fn-b']) + expect(live.container.querySelector('section.footnotes')?.textContent).toContain('First note body. ↩') + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + live.unmount() + }) + + it('keeps every frozen block DOM node through the rest of the stream', () => { + const paragraphs = Array.from({ length: 12 }, (_, i) => `Stable paragraph ${i}.`) + const half = `${paragraphs.slice(0, 6).join('\n\n')}\n\n` + const live = render() + const captured = [...live.container.querySelectorAll('p')] + expect(captured.length).toBe(6) + let text = half + for (const paragraph of paragraphs.slice(6)) { + text += `${paragraph}\n\n` + live.rerender() + } + const finalNodes = [...live.container.querySelectorAll('p')] + expect(finalNodes.slice(0, 6)).toEqual(captured) + expect(finalNodes).toHaveLength(12) + live.unmount() + }) + + it('renders an empty document for definition-only streams, including trailing blank lines', () => { + const doc = '[a]: https://example.com/1\n\n[b]: https://example.com/2\n\n[c]: https://example.com/3\n\n[d]: https://example.com/4' + const live = render() + live.rerender() + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + expect(live.container.querySelector('div')?.childNodes).toHaveLength(0) + fresh.unmount() + live.unmount() + }) + + it('survives streaming → settled → streaming prop flips with a fresh incremental state', () => { + const live = render() + live.rerender() + const settled = render() + expect(live.container.innerHTML).toBe(settled.container.innerHTML) + settled.unmount() + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + live.unmount() + }) + + it('matches fresh renders under irregular deterministic chunk sizes', () => { + let seed = 42 + const nextSize = (): number => { + seed = (seed * 1103515245 + 12345) % 2147483648 + return 1 + (seed % 13) + } + const live = render() + let end = 0 + while (end < STREAM_DOC.length) { + end = Math.min(end + nextSize(), STREAM_DOC.length) + const prefix = STREAM_DOC.slice(0, end) + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + } + live.unmount() + }) +}) + +describe('IncrementalMarkdownParser', () => { + it('freezes all but the trailing two blocks and keeps freezing as blocks appear', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + const first = parser.update('a\n\nb\n\nc\n\nd\n\ne') + expect(first.frozen.map(b => b.node.type)).toEqual(['paragraph', 'paragraph', 'paragraph']) + expect(first.tail).toHaveLength(2) + const second = parser.update('a\n\nb\n\nc\n\nd\n\ne\n\nf\n\ng') + expect(second.frozen).toHaveLength(5) + expect(second.tail).toHaveLength(2) + // Previously returned frozen entries keep their identity and keys. + expect(second.frozen.slice(0, 3)).toEqual(first.frozen) + expect(second.generation).toBe(first.generation) + }) + + it('holds every block in the tail until more than two exist', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + const result = parser.update('only\n\ntwo blocks') + expect(result.frozen).toHaveLength(0) + expect(result.tail).toHaveLength(2) + }) + + it('returns the cached result for identical input', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + const first = parser.update('a\n\nb\n\nc') + expect(parser.update('a\n\nb\n\nc')).toBe(first) + }) + + it('bumps the generation and discards frozen blocks on non-append input', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + const before = parser.update('a\n\nb\n\nc\n\nd') + expect(before.frozen.length).toBeGreaterThan(0) + const after = parser.update('different') + expect(after.generation).toBe(before.generation + 1) + expect(after.frozen).toHaveLength(0) + expect(after.tail.map(b => b.node.type)).toEqual(['paragraph']) + }) + + it('keys blocks by absolute source offset across freezes', () => { + const doc = 'aaa\n\nbbb\n\nccc\n\nddd\n\neee' + const parser = new IncrementalMarkdownParser(parseGfm) + const grown = parser.update(doc) + const oneShotKeys = parseGfm(doc).children.map(node => node.position?.start.offset) + expect([...grown.frozen, ...grown.tail].map(b => b.key)).toEqual(oneShotKeys) + }) + + it('never freezes under a grammar that omits positions', () => { + const bare = (text: string): Root => { + const root = parseGfm(text) + const strip = (nodes: RootContent[]): void => { + for (const node of nodes) { + delete node.position + if ('children' in node) strip(node.children) + } + } + strip(root.children) + return root + } + const parser = new IncrementalMarkdownParser(bare) + const result = parser.update('a\n\nb\n\nc\n\nd\n\ne') + expect(result.frozen).toHaveLength(0) + expect(result.tail).toHaveLength(5) + // Fallback keys stay unique per sibling. + expect(new Set(result.tail.map(b => b.key)).size).toBe(5) + }) +}) diff --git a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx new file mode 100644 index 0000000000..48dd03f5c5 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx @@ -0,0 +1,225 @@ +// @vitest-environment jsdom +// Branch coverage for the mdast renderer that real parses cannot reach: the +// grammar only emits references whose definitions exist, always stamps +// positions and align arrays, and never emits bare list items — but the +// renderer is a pure function over mdast, so hand-built trees exercise its +// defensive arms directly. +import { StrictMode } from 'react' +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type * as Md from 'mdast' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { + collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, +} from '../src/markdown/render.tsx' +import type { MarkdownRenderContext } from '../src/markdown/render.tsx' + +afterEach(cleanup) + +function makeContext(): MarkdownRenderContext { + return { + streaming: false, + codeLabels: undefined, + targets: createReferenceTargets(), + footnoteOrder: [], + footnoteCounts: new Map(), + } +} + +function renderNodes(nodes: Md.RootContent[], context = makeContext()): HTMLElement { + const { container } = render( +
              {renderBlocks(nodes.map((node, key) => ({ node, key })), context)}
              , + ) + return container +} + +const text = (value: string): Md.Text => ({ type: 'text', value }) + +describe('renderBlocks over hand-built trees', () => { + it('reverts unresolved references to their bracketed source', () => { + const container = renderNodes([ + { + type: 'paragraph', + children: [ + { type: 'linkReference', identifier: 'a', referenceType: 'shortcut', children: [text('one')] }, + { type: 'linkReference', identifier: 'b', referenceType: 'collapsed', children: [text('two')] }, + { type: 'linkReference', identifier: 'c', label: 'C', referenceType: 'full', children: [text('three')] }, + { type: 'imageReference', identifier: 'd', referenceType: 'full', alt: 'pic' }, + { type: 'imageReference', identifier: 'e', referenceType: 'shortcut', alt: null }, + ], + }, + ]) + expect(container.textContent).toBe('[one][two][][three][C]![pic][d]![]') + expect(container.querySelector('a')).toBeNull() + }) + + it('keeps the first definition when identifiers repeat', () => { + const targets = createReferenceTargets() + collectReferenceTargets([ + { type: 'definition', identifier: 'dup', url: 'https://example.com/first' }, + { type: 'definition', identifier: 'dup', url: 'https://example.com/second' }, + { type: 'footnoteDefinition', identifier: 'fn', children: [] }, + { type: 'footnoteDefinition', identifier: 'fn', children: [{ type: 'paragraph', children: [text('late')] }] }, + ], targets) + expect(targets.definitions.get('DUP')?.url).toBe('https://example.com/first') + expect(targets.footnotes.get('FN')?.children).toEqual([]) + }) + + it('renders a bare list item, computing looseness from the item itself', () => { + const item: Md.ListItem = { + type: 'listItem', + spread: null, + children: [ + { type: 'paragraph', children: [text('alpha')] }, + { type: 'paragraph', children: [text('beta')] }, + ], + } + const container = renderNodes([item]) + // Two block children make the parentless item loose: paragraphs stay wrapped. + expect([...container.querySelectorAll('li > p')].map(p => p.textContent)).toEqual(['alpha', 'beta']) + }) + + it('renders spread-null lists and align-less tables', () => { + const container = renderNodes([ + { + type: 'list', + ordered: false, + spread: null, + children: [{ type: 'listItem', spread: null, children: [{ type: 'paragraph', children: [text('solo')] }] }], + }, + { + type: 'table', + children: [ + { type: 'tableRow', children: [{ type: 'tableCell', children: [text('h')] }] }, + { type: 'tableRow', children: [{ type: 'tableCell', children: [text('short')] }] }, + ], + }, + ]) + expect(container.querySelector('li')?.textContent).toBe('solo') + expect(container.querySelector('th')?.getAttribute('style')).toBeNull() + expect(container.querySelector('td')?.textContent).toBe('short') + }) + + it('pads rows against the alignment width with empty cells', () => { + const container = renderNodes([ + { + type: 'table', + align: ['left', 'right'], + children: [ + { type: 'tableRow', children: [{ type: 'tableCell', children: [text('only')] }] }, + ], + }, + ]) + const cells = [...container.querySelectorAll('th')] + expect(cells).toHaveLength(2) + expect(cells[1]?.textContent).toBe('') + }) + + it('renders a checked item without any content as a bare checkbox', () => { + const container = renderNodes([ + { + type: 'list', + ordered: false, + children: [ + { type: 'listItem', checked: true, children: [] }, + { type: 'listItem', checked: false, children: [{ type: 'paragraph', children: [] }] }, + ], + }, + ]) + const items = [...container.querySelectorAll('li.task-list-item')] + expect(items).toHaveLength(2) + for (const item of items) { + expect(item.querySelector('input[type="checkbox"]')).not.toBeNull() + expect(item.textContent?.trim()).toBe('') + } + }) + + it('renders images with a null alt as an empty alt attribute', () => { + const targets = createReferenceTargets() + targets.definitions.set('R', { type: 'definition', identifier: 'r', url: 'https://example.com/r.png' }) + const container = renderNodes([ + { type: 'paragraph', children: [{ type: 'image', url: 'https://example.com/x.png', alt: null }] }, + { type: 'paragraph', children: [{ type: 'imageReference', identifier: 'r', referenceType: 'full', alt: null }] }, + ], { ...makeContext(), targets }) + const images = [...container.querySelectorAll('img')] + expect(images.map(image => image.getAttribute('alt'))).toEqual(['', '']) + }) + + it('drops a definition nested in a list item without leaving a separator behind', () => { + const container = renderNodes([ + { + type: 'list', + ordered: true, + start: 3, + children: [{ + type: 'listItem', + children: [ + { type: 'paragraph', children: [text('body')] }, + { type: 'definition', identifier: 'x', url: 'https://example.com' }, + ], + }], + }, + ]) + expect(container.querySelector('ol')?.getAttribute('start')).toBe('3') + // The two mdast children make the item loose (wrap newlines around the + // paragraph); the dropped definition contributes nothing else. + expect(container.querySelector('li')?.textContent).toBe('\nbody\n') + }) + + it('renders nothing for node types without a mapping', () => { + const container = renderNodes([ + { type: 'yaml', value: 'front: matter' }, + { type: 'tableRow', children: [] }, + { type: 'paragraph', children: [text('after')] }, + ]) + expect(container.textContent).toBe('after') + }) +}) + +describe('renderFootnoteSection edge shapes', () => { + it('skips referenced footnotes without definitions and returns null when none remain', () => { + const context = makeContext() + context.footnoteOrder.push('GHOST') + context.footnoteCounts.set('GHOST', 1) + expect(renderFootnoteSection(context)).toBeNull() + }) + + it('renders no back-reference markers for an uncounted footnote', () => { + const context = makeContext() + context.targets.footnotes.set('Q', { + type: 'footnoteDefinition', + identifier: 'q', + children: [{ type: 'paragraph', children: [text('quiet')] }], + }) + context.footnoteOrder.push('Q') + const { container } = render(
              {renderFootnoteSection(context)}
              ) + expect(container.querySelector('li')?.textContent).toBe('\nquiet \n') + }) + + it('appends back-references after a non-paragraph body', () => { + const context = makeContext() + context.targets.footnotes.set('N', { + type: 'footnoteDefinition', + identifier: 'n', + children: [{ type: 'code', value: 'code body', lang: null }], + }) + context.footnoteOrder.push('N') + context.footnoteCounts.set('N', 1) + const { container } = render(
              {renderFootnoteSection(context)}
              ) + const item = container.querySelector('li') + expect(item?.querySelector('.md-code-block')).not.toBeNull() + expect(item?.textContent).toContain('↩') + }) +}) + +describe('MarkdownText under StrictMode', () => { + it('streams identically when React double-invokes render work', () => { + const doc = 'one\n\ntwo\n\nthree\n\nfour\n\nfive' + const strict = render() + strict.rerender() + const plain = render() + expect(strict.container.innerHTML).toBe(plain.container.innerHTML) + strict.unmount() + plain.unmount() + }) +}) diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 7a858c1199..5066e32cd2 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import type { Extension } from 'micromark-util-types' import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' -import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts' +import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' +import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' afterEach(cleanup) @@ -68,6 +68,100 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() }) + it('closes punctuation-terminated strong emphasis before adjacent CJK text', () => { + const cases = [ + ['**注意:**内容', '注意:'], + ['**Notice:**内容', 'Notice:'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], + ['**句号。**后续', '句号。'], + ['**Period.**后续', 'Period.'], + ['**提醒!**继续', '提醒!'], + ['**Warning!**继续', 'Warning!'], + ] as const + const source = cases.map(([markdown]) => markdown).join('\n\n') + + for (const streaming of [false, true]) { + const rendered = render() + expect([...rendered.container.querySelectorAll('strong')].map(node => node.textContent)) + .toEqual(cases.map(([, strong]) => strong)) + rendered.unmount() + } + }) + + it('keeps the CJK strong extension out of escaped, code, math, and ASCII contexts', () => { + const source = [ + String.raw`\**注意:**内容`, + '`**注意:**内容`', + '**Notice:**text', + '*提醒!*继续', + '$**注意:**内容$', + '```md', + '**注意:**内容', + '```', + '**普通**内容', + '*普通*内容', + ].join('\n\n') + const { container } = render() + + expect([...container.querySelectorAll('strong')].map(node => node.textContent)).toEqual(['普通']) + expect([...container.querySelectorAll('em')].map(node => node.textContent)).toEqual(['普通']) + expect(container.querySelector('code')?.textContent).toBe('**注意:**内容') + expect(container.querySelector('.katex annotation')?.textContent).toBe('**注意:**内容') + expect(container.querySelector('pre code')?.textContent).toContain('**注意:**内容') + expect(container.textContent).toContain('**Notice:**text') + expect(container.textContent).toContain('*提醒!*继续') + expect(container.textContent).toContain('**注意:**内容') + }) + + it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => { + const localUrl = 'http://127.0.0.1:3199/?demo=1' + const remoteUrl = 'https://example.com/preview?q=one%20two#result' + const source = [ + `\`${localUrl}\``, + `\`${remoteUrl}\``, + '`curl http://127.0.0.1:3199/?demo=1`', + '`javascript:alert(1)`', + '`mailto:dev@example.com`', + `\` ${localUrl} \``, + '```', + localUrl, + '```', + ].join('\n\n') + const { container } = render() + + const links = screen.getAllByRole('link') + expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl]) + for (const link of links) { + expect(link.closest('code')).not.toBeNull() + expect(link.getAttribute('target')).toBe('_blank') + expect(link.getAttribute('rel')).toBe('noopener noreferrer') + } + links[0]?.focus() + expect(document.activeElement).toBe(links[0]) + expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull() + expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull() + expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull() + const paddedCode = [...container.querySelectorAll('code')] + .find(code => code.textContent === ` ${localUrl} `) + expect(paddedCode?.querySelector('a')).toBeNull() + expect(container.querySelector('pre code a')).toBeNull() + }) + + it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => { + const extension = cjkFriendlyStrong() + expect(cjkFriendlyStrong()).toBe(extension) + const construct = extension.text?.[42] + const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize + expect(tokenizer).toBeTypeOf('function') + expect(() => tokenizer?.call({ + parser: { constructs: { attentionMarkers: {} } }, + previous: null, + } as never, {} as never, () => undefined, () => undefined)).toThrow( + 'micromark CommonMark attention markers are unavailable', + ) + }) + it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => { for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) { const { container, unmount } = render() @@ -321,11 +415,11 @@ describe('MarkdownText', () => { expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$') }) - it('registers the compatibility extension on a bare remark processor', () => { - const data: { micromarkExtensions?: Extension[] } = {} - remarkMathCompatibility.call({ data: () => data }) + it('exposes the compatibility syntax as a micromark extension', () => { + const extension = mathCompatibility() - expect(data.micromarkExtensions).toHaveLength(1) + expect(Object.keys(extension)).toEqual(['flow', 'text']) + expect(mathCompatibility()).toBe(extension) }) it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => { diff --git a/packages/client/ui-primitives/tests/onboarding-surface.spec.tsx b/packages/client/ui-primitives/tests/onboarding-surface.spec.tsx new file mode 100644 index 0000000000..73604c9644 --- /dev/null +++ b/packages/client/ui-primitives/tests/onboarding-surface.spec.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives' + +let appRoot: HTMLDivElement + +beforeEach(() => { + appRoot = document.createElement('div') + appRoot.id = 'root' + document.body.appendChild(appRoot) +}) + +afterEach(() => { + cleanup() + appRoot.remove() +}) + +describe('OnboardingSurface', () => { + it('portals the overlay chrome to document.body around its content', () => { + const view = render(

              step content

              ) + // Portaled: the overlay is a body child, not inside the render container. + expect(view.container.querySelector('[class*="onboardingOverlay"]')).toBeNull() + const overlay = document.body.querySelector('[class*="onboardingOverlay"]') + expect(overlay).not.toBeNull() + // The onboarding e2e pins the mask by class substring; the stage carries + // the content. + expect(overlay!.querySelector('[class*="onboardingMask"]')).not.toBeNull() + const stage = overlay!.querySelector('[class*="onboardingStage"]') + expect(stage).not.toBeNull() + expect(stage!.textContent).toBe('step content') + }) + + it('holds #root inert for exactly its own lifetime', () => { + const view = render(x) + expect(appRoot.inert).toBe(true) + view.unmount() + expect(appRoot.inert).toBe(false) + }) + + it('renders without an #root element (compositions that mount elsewhere)', () => { + appRoot.remove() + const view = render(x) + expect(document.body.querySelector('[class*="onboardingStage"]')!.textContent).toBe('x') + view.unmount() + }) +}) diff --git a/packages/client/ui-question/src/client/index.ts b/packages/client/ui-question/src/client/index.ts index 8cc25aeb88..73d2d656b1 100644 --- a/packages/client/ui-question/src/client/index.ts +++ b/packages/client/ui-question/src/client/index.ts @@ -36,13 +36,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'question' -/** - * Required services (cordis fiber inject). 'conversation' is an ordering - * edge, not a call dependency: the 'conversation.composer' chain slot is - * declared by ui-conversation's apply, and register() into an undeclared - * slot throws — service waiting orders this apply after the declaring one. - */ -export const inject = ['slots', 'conversation', 'locale'] +/** Required services: the slot registry and the question composer's copy. */ +export const inject = ['slots', 'locale'] /** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null { @@ -58,11 +53,8 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries') - ctx.effect( - () => ctx.slots.register( - { name: 'conversation.composer', select: selectQuestion, locale: NS }, - QuestionComposer, - ), - 'ui-question: composer chain registration', - ) + ctx.slots.inject('conversation.composer', () => ctx.slots.register( + { name: 'conversation.composer', select: selectQuestion, locale: NS }, + QuestionComposer, + )) } diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index 0acc7fac82..01b077a29a 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -2,7 +2,7 @@ * apply wiring on a real cordis Context + SlotsService: QuestionComposer * registered as the `question` entry of the conversation-declared composer * slot with ZERO business face (data and verbs ride the dispatched carrier), - * load-order fail-loud, and fiber-teardown unregistration. Component and + * declaration-aware activation, and fiber-teardown unregistration. Component and * domain-face behavior is covered props-direct in question-composer.spec.tsx; * no renderer machinery here. */ @@ -22,27 +22,28 @@ async function bench() { { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, () => null, ) - // 'conversation' inject is an ordering edge (the declaring plugin provides - // it after declaring the chain); the bench declares the chain itself. - ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) return { ctx, slots } } describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'conversation', 'locale']) + expect(inject).toEqual(['slots', 'locale']) }) - it('fails loud when no live entry has declared the composer slot', async () => { + it('waits until a live entry declares the composer slot', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() - // Satisfy the ordering inject without declaring the chain: apply must - // then hit the undeclared-slot throw, not sit waiting on the service. - ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) - await expect(ctx.plugin({ inject: [...inject], apply })) - .rejects.toThrow(/slot "conversation.composer" is not declared/) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.slots.entries('conversation.composer')).toHaveLength(0) + ctx.slots.register( + { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, + () => null, + ) + await Promise.resolve() + expect(ctx.slots.entries('conversation.composer')).toHaveLength(1) }) it('registers the question entry: routing selector, no inject face', async () => { diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx index c25b1bf3aa..34187073a1 100644 --- a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef } from 'react' import type { ReactNode } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives' +import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts' import css from './WelcomeNotice.module.css' @@ -55,6 +55,9 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus() }, [state.acknowledged, state.status]) + // Null while the acknowledgement fact is still loading (or already given): + // the takeover chrome below is part of THIS render, so deciding not to + // show paints and blocks nothing. if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null const acknowledge = async (): Promise => { @@ -62,25 +65,27 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { } return ( -
              - -

              {t('welcome.title')}

              -

              {t('welcome.paragraph.0')}

              -
              {t('welcome.paragraph.1')}
              -

              - {emphasizedFeedback(t('welcome.paragraph.2'), t('welcome.feedbackEmphasis'))} -

              - {state.error === null ? null :

              {t('welcome.error')}

              } -
              - -
              -
              + +
              + +

              {t('welcome.title')}

              +

              {t('welcome.paragraph.0')}

              +
              {t('welcome.paragraph.1')}
              +

              + {emphasizedFeedback(t('welcome.paragraph.2'), t('welcome.feedbackEmphasis'))} +

              + {state.error === null ? null :

              {t('welcome.error')}

              } +
              + +
              +
              +
              ) } diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 888b30e21e..9d57a0eba9 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -6,7 +6,6 @@ * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). @@ -50,7 +49,7 @@ const NS = 'settings' /** * Required services (cordis fiber inject). The target slots are declared by * ui-settings' apply, whose activation order relative to this one is NOT - * constrained; registration goes through declaration-aware deferral. + * constrained; registrations depend on their slots through `slots.inject()`. */ export const inject = ['slots', 'locale', 'connection'] @@ -97,47 +96,34 @@ export function apply(ctx: ClientContext): void { ] return () => { for (const dispose of disposers) dispose() } }, 'ui-settings-general: metadata invalidations') - ctx.effect(() => { - const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () => - ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) - const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () => - ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent)) - const action = documentInjected === undefined - ? undefined - : deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () => - ctx.slots.register({ - name: 'settings.action', - id: 'open-document', - order: 0, - locale: NS, - inject: documentInjected, - }, SettingsDocumentAction)) - const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () => - ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel)) - const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => - ctx.slots.register({ - name: 'settings.section', - id: 'general', - order: 0, - label: () => t('general.nav'), - locale: NS, - children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, - }, GeneralSection)) - const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () => - ctx.slots.register({ - name: 'settings.onboarding', - id: 'welcome-notice', - order: -100, - locale: NS, - inject: welcomeInjected, - }, WelcomeNotice)) - return () => { - trigger.dispose() - header.dispose() - action?.dispose() - close.dispose() - general.dispose() - welcome.dispose() - } - }, 'ui-settings-general: chrome, action, section, and onboarding registrations') + ctx.slots.inject('settings.trigger', () => + ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) + ctx.slots.inject('settings.header', () => + ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent)) + if (documentInjected !== undefined) { + ctx.slots.inject('settings.action', () => ctx.slots.register({ + name: 'settings.action', + id: 'open-document', + order: 0, + locale: NS, + inject: documentInjected, + }, SettingsDocumentAction)) + } + ctx.slots.inject('settings.close', () => + ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel)) + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: () => t('general.nav'), + locale: NS, + children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, + }, GeneralSection)) + ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ + name: 'settings.onboarding', + id: 'welcome-notice', + order: -100, + locale: NS, + inject: welcomeInjected, + }, WelcomeNotice)) } diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 989fb18e64..5871fb996f 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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/README.md -README.md: de78d599b7833179339ceeb680fbd665b056bd83 -README.zh.md: 8ae3bdf34f59ca03e4796c354df739aa9fe29bd9 +README.md: 785f0417f00ec8eb1f8c9273b4d81f8ca5ca1810 +README.zh.md: 8e7bd7325b78416345985ee25a56a5eb8b382478 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index de78d599b7..785f0417f0 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). -The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source. +The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source. ## Model Experience diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 8ae3bdf34f..8e7bd7325b 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -4,7 +4,7 @@ 设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 -外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 +外壳将首次使用引导记录按升序投影,每次只挂载一个页面;接管界面框架(body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及页面的外层包裹均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 ## 模型体验 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 72c188e019..e70558081a 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -219,33 +219,3 @@ clip: rect(0 0 0 0); white-space: nowrap; } - -/* First-run stage: keep the product top bar visible, then let onboarding own - the complete workspace instead of presenting another settings modal. */ -.onboardingOverlay { - position: fixed; - inset: 0; - z-index: 1100; -} - -/* Mask */ -.onboardingMask { - position: absolute; - left: 0px; - right: 0px; - top: 80px; - bottom: 0px; - background: rgba(0, 0, 0, 0.24); - /* Mask-blur */ - backdrop-filter: blur(2px); -} - -.onboardingStage { - position: absolute; - z-index: 1; - inset: 0; - display: flex; - justify-content: center; - overflow: hidden; - background: var(--dsw-alias-bg-layer-1); -} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 45055753ac..d6b2e8ef5a 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -7,10 +7,11 @@ * aria-labelledby the title node; close: visually-hidden slot text). Modal * open state and the active section id are component-local viewing state; * the onboarding coordinator mounts exactly one ordered registrant while the - * sessions-derived empty-Hero fact is active. + * sessions-derived empty-Hero fact is active — the takeover chrome + * (OnboardingSurface) belongs to the step, so a mounted-but-deciding step + * paints nothing here. */ import { useCallback, useEffect, useId, useRef, useState } from 'react' -import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' @@ -134,14 +135,6 @@ export function SettingsRoot(props: SettingsRootComponentProps) { }) }, []) - useEffect(() => { - if (onboardingStep === undefined) return - const appRoot = document.getElementById('root') - if (appRoot === null) return - appRoot.inert = true - return () => { appRoot.inert = false } - }, [onboardingStep]) - return ( <>