diff --git a/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml new file mode 100644 index 0000000000..71c2bd9db9 --- /dev/null +++ b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md +2026-09-08-web-explicit-file-delivery.md: ff2ddeb59ded05b70006dce217df2966eab9d4f2 +2026-09-08-web-explicit-file-delivery.zh.md: 85b09ac82c83365b1c198168b78aa7ac84ef216f diff --git a/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md new file mode 100644 index 0000000000..ff2ddeb59d --- /dev/null +++ b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md @@ -0,0 +1,38 @@ +# Agent Note: Web delivers explicit file snapshots + +Status: implemented +Archived: 2026-09-08 + +English | [中文](2026-09-08-web-explicit-file-delivery.zh.md) + +## Problem + +Workspace links read live paths, so edits or deletion can invalidate a final deliverable. Files created through shell commands also lack first-party editor mutation records. Delivery needs an explicit operation and saved bytes without expanding Session ZIP exports. + +## Decision + +The [present tool](../../../../packages/fs/tool-present/README.md) owns execution, immutable snapshots, delivery types, and the durable event. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) owns authenticated snapshot actions and browser rendering, with type-only imports from the tool’s `./types` entry. The `standard`, `ptc`, and `cordis` presets mount the tool package; `minimal` retains its two-tool training configuration. The existing attachment service saves immutable bytes; successful final `tools/result` notifications append `deliverables/presented` to the calling Session. Native and nested calls use the same recorder. A later enclosing program failure does not undo a completed nested delivery. Blocked tool results publish none. + +Download and native-open requests authorize a reference by the viewed Session, event sequence, and file index. The event stores no Session ID, so forked history uses the child's own log. The existing produced-file row keeps its names and behavior. Session ZIP retains delivery events but does not collect their attachment bytes. + +Card and closing-mention gestures open a verified private copy with the existing native-command utility. A POST expresses the desktop side effect; GET remains a byte read. Each gesture receives a new copy so application edits cannot corrupt the immutable attachment or alter later opens. Successful copies survive until plugin disposal for applications that read lazily; failed copies are removed immediately, and disposal awaits cancelled work before cleanup. + +## Alternatives considered + +**A Host tool subpath in the UI package** couples preset installation to browser packaging and requires extra published entries. An ordinary tool package preserves shared filesystem and tool error classes through the repository’s peer dependency rules. + +**Live workspace links** cannot preserve a delivered version after edits or deletion. Opening the attachment store’s own path instead would expose immutable saved bytes to application writes. + +**Generic artifact fields throughout tools, dispatch, and Session** would broaden unrelated APIs for one Web feature. A plugin-owned event uses existing extension points and avoids parent-result forwarding. + +**Tool text as the durable index** is unreliable because post-processing and spill can replace ordinary or nested result text. Each plugin instance retains its own completed snapshots by execution identity and publishes them only on a successful final result. Same-name scoped replacements cannot create or duplicate another instance’s delivery records. + +**Descriptor-bound filesystem extensions** would change multiple capability providers. This feature uses existing bounded reads with containment and before/after version checks. Those checks reject ordinary concurrent changes but do not guarantee atomic confinement against swap-and-restore; stronger filesystem guarantees belong to the filesystem provider. + +## Consequences + +The implementation adds no artifact service or attachment format. Unreferenced snapshots can remain after partial failure; attachment retention remains service-owned. A downstream build must understand the new required event to read the log. The generated Session event inventory records that requirement without changing released format generations. + +The delivery event is required-on-read because it is the authorization index for saved bytes, not only display metadata. Skipping it would allow an older reader to reconstruct or fork a Session without its completed deliveries. Unsupported readers refuse that loss instead of silently dropping the references. + +Focused tests cover snapshot bytes, invalid inputs, blocked results, HTTP integrity, native-open copy isolation, retry and disposal, turn isolation, and fork-addressed actions. The recorded Web scenario covers nested completion followed by an enclosing failure, source deletion, reload, native-open gestures without browser downloads, and ZIP exclusion. diff --git a/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md new file mode 100644 index 0000000000..85b09ac82c --- /dev/null +++ b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md @@ -0,0 +1,38 @@ +# Agent Note: Web 显式交付文件快照 + +Status: implemented +Archived: 2026-09-08 + +[English](2026-09-08-web-explicit-file-delivery.md) | 中文 + +## 问题 + +工作区链接读取当前路径,因此编辑或删除会使最终交付文件失效。通过 shell 命令创建的文件也没有第一方编辑器修改记录。交付需要显式操作和保存的字节,同时不扩大 Session ZIP 导出内容。 + +## 决策 + +[present 工具](../../../../packages/fs/tool-present/README.zh.md)拥有执行、不可变快照、交付类型和持久事件。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)拥有认证快照操作和浏览器渲染,仅从工具的 `./types` 入口导入类型。`standard`、`ptc` 与 `cordis` preset 挂载工具包;`minimal` 保留双工具训练配置。现有 attachment 服务保存不可变字节;成功的最终 `tools/result` 通知将 `deliverables/presented` 追加到调用方 Session。原生与嵌套调用使用同一个记录器。外层程序随后失败不会撤销已完成的嵌套交付。被阻止的工具结果不发布交付。 + +下载与原生打开请求通过当前查看的 Session、事件序号与文件索引授权引用。事件不保存 Session ID,因此 fork 历史使用子 Session 自己的日志。现有产出文件行保留其名称和行为。Session ZIP 保留交付事件,但不收集其中引用的 attachment 字节。 + +卡片和收尾引用操作通过现有 native-command 工具,在默认应用中打开经过校验的私有副本。POST 表达桌面副作用;GET 仍仅读取字节。每次操作创建新副本,避免应用内编辑损坏不可变 attachment 或改变后续打开的内容。成功副本保留到插件释放,以支持延迟读取的应用;失败副本立即删除,释放时先等待取消的操作结束再清理。 + +## 已考虑的替代方案 + +**在 UI 包中提供 Host 工具子路径**会将 preset 安装与浏览器打包耦合,并要求额外发布入口。普通工具包通过仓库 peer dependency 规则保留共享的文件系统和工具错误类。 + +**实时工作区链接**无法在编辑或删除后保留已交付版本。直接打开 attachment 存储路径则会使不可变保存字节暴露于应用写入。 + +**在工具、dispatch 和 Session 中增加通用 artifact 字段**会为单个 Web 功能扩大无关 API。插件拥有的事件使用现有扩展点,并省去父调用结果转发。 + +**将工具文本作为持久索引**并不可靠,因为后处理与 spill 可以替换普通或嵌套结果文本。每个插件实例按执行对象保留自身已完成的快照,仅在最终结果成功时发布。同名作用域替代工具不能创建或重复其他实例的交付记录。 + +**基于文件描述符的文件系统扩展**会修改多个能力提供方。本功能使用现有有界读取,并检查路径包含关系及读取前后的版本。这些校验会拒绝普通并发变化,但不保证对替换后复原提供原子路径限制;更强的文件系统保证属于文件系统提供方。 + +## 影响 + +实现不增加 artifact 服务或 attachment 格式。部分失败后可能留下无引用快照;attachment 保留策略仍由服务拥有。下游构建必须理解新必需事件才能读取日志。生成的 Session 事件清单记录该要求,不修改已发布的格式代际。 + +交付事件要求读取端识别,因为它是保存字节的授权索引,不只是显示元数据。跳过事件会让旧读取端在重建或分叉 Session 时丢失已完成的交付。不支持该事件的读取端拒绝读取,避免静默丢弃引用。 + +定向测试覆盖快照字节、无效输入、被阻止的结果、HTTP 完整性、原生打开的副本隔离、重试与释放、turn 隔离及使用 fork 地址的操作。录制 Web 场景覆盖嵌套调用完成后外层失败、源文件删除、重新加载、不触发浏览器下载的原生打开操作和 ZIP 排除。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index f540320056..cded9f6c7e 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -1381,6 +1381,9 @@ "feature/2026-09-01-web-superellipse-corner-smoothing.i18n.yaml": "sha256:50afdbe5b5e19889918af6d86ab3218c05205be35938b6d33d158c60777e3b58", "feature/2026-09-01-web-superellipse-corner-smoothing.md": "sha256:b1445101c49e74bbcb4f607af850cd6df105d4034828d0dd47081e8079148f15", "feature/2026-09-01-web-superellipse-corner-smoothing.zh.md": "sha256:1a278c417c0d7de3b4c3c35061b419303b4a1a0707831c283d8f862ab9b6fd23", + "feature/2026-09-08-web-explicit-file-delivery.i18n.yaml": "sha256:99daae539cc8fd7376ce0265538bee21e1e33f3c0d77c8cc4e011f94b4e9568a", + "feature/2026-09-08-web-explicit-file-delivery.md": "sha256:bb416b1e8be081e6cb6af17792eb1a442172ff114c3a3577e5ef06a77eb57093", + "feature/2026-09-08-web-explicit-file-delivery.zh.md": "sha256:00a642380e1f6ac9d5cd840e021f4e3e4ae68a289cb6344eb3dcd73a6fd81f4d", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml index ed7ac8f6d5..7f14684d59 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.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-session-surface.md -2026-06-18-session-surface.md: 0139cc4beba766e4e8b936594899649304234eaa -2026-06-18-session-surface.zh.md: 0596d2a0425890924276265dd9cc6c32fcffb974 +2026-06-18-session-surface.md: 93ea55883dedd943fe1ffac67a9842c962ca6dac +2026-06-18-session-surface.zh.md: 54ecb1162bc46007dfcbb7d8cb39075d52171567 diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md index 0139cc4beb..93ea55883d 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md @@ -12,36 +12,32 @@ The event log is authoritative, but history manipulation had no durable shared m Add a **surface** — a derived, cached order of event sequences (the subset of events that produce LLM messages) — maintained by `surfaceOp` markers in the event log. -### Two new top-level fields on `SessionEvent` +### Top-level surface metadata on `SessionEvent` -Every `SessionEvent` gains two optional fields (structural metadata, like `seq`/`time`): +Surface metadata belongs only to the four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`): -- **`sourceEventSeqs?: number[]`** — seq numbers of earlier events cited as sources, such as a `tool/call` cited by its result or surface nodes shadowed by a compaction marker. A present list is non-empty, unique, earlier, and known. V2 `assistant/message` embeds its provider stream and cannot carry this field. Without cited seqs, replay cannot validate that a replace-range operation names every event it removed. -- **`surfaceOp?: SurfaceOp`** — how this event entered the surface. Absent for non-surface events. +- **`sourceEventSeqs?: SessionSeq[]`** — seq numbers of earlier events cited as sources, such as a `tool/call` cited by its result or surface nodes shadowed by a compaction marker. A present list is non-empty, unique, earlier, and known. `assistant/message` embeds its provider stream and cannot carry this field. Without cited seqs, replay cannot validate that a replace-range operation names every event it removed. +- **`surfaceOp: SurfaceOp`** — required placement for every surface event. Known log-only events forbid both metadata fields; native unknown or obsolete ignorable envelopes remain opaque. ### SurfaceOp: two operations -```ts -export type SurfaceOp = - | 'append' // normal tail append - | { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive -``` +The [source-backed `SurfaceOp` reference](../../../../docs/subsystems/session.md#surface-types) defines the exact union. Replacement objects contain only `op`, `startSeq`, and `endSeq`; endpoints use the `SessionSeq` brand. -1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: `tool/result` records its `tool/call` source, while `assistant/message` owns its embedded stream directly. +1. **Append** — add the new event seq to the tail. Used by `system/message`, `user/message`, `assistant/message`, `tool/result`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: `tool/result` records its `tool/call` source, while `assistant/message` owns its embedded stream directly. -2. **Replace** — remove entries from `start` through `end` (both inclusive) and insert the new event seq in their place. Both `start` and `end` must be present in the current surface; `start === end` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface. +2. **Replace** — remove entries from `startSeq` through `endSeq` (both inclusive) and insert the new event seq in their place. Both `startSeq` and `endSeq` must be present in the current surface; `startSeq === endSeq` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface. ### SurfaceManager: delta-based, not full rebuild -A `Session` owns one `SurfaceManager` that maintains an ordered `number[]` of event seqs. The manager validates each seed or append candidate without applying it before commit, then processes only committed events since its previous synchronization rather than rescanning the entire log. `Session.surface` exposes the same manager through the readonly `SessionSurface` contract, so acceptance, derived history, compaction, and workspace context share one incremental state. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no second manager, link objects, or seq-to-node map duplicates the order. +A `Session` owns one `SurfaceManager` that maintains an ordered `SessionSeq[]` of event seqs. The manager validates each seed or append candidate without applying it before commit, then processes only committed events since its previous synchronization rather than rescanning the entire log. `Session.surface` exposes the same manager through the readonly `SessionSurface` contract, so acceptance, derived history, compaction, and workspace context share one incremental state. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no second manager, link objects, or seq-to-node map duplicates the order. Delta processing is O(1) when no new events and O(new events) when new events arrive. -`deriveMessages()` uses the surface when surface markers exist, falling back to the existing linear scan for sessions without markers (backward compatibility). +`deriveMessages()` walks the surface as its sole derivation path. A surface event without its required marker is invalid, not an implicit append. ### Persistence -The new fields are serialized as top-level JSON properties. JSONL storage requires no separate column mapping: its lossless JSON boundary preserves both values. Released v0 and v1 share this surface representation, and the identity v0-to-v1 edge preserves it exactly; a future structural representation change increments `SESSION_FORMAT_VERSION` and owns an adjacent migration. +The fields are serialized as top-level JSON properties. JSONL preserves placement and provenance without a separate column mapping. The [V3 canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns exact replacement keys and strict-acceptance rationale; the [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes) owns historical conversion. This note retains ordered-projection ownership and replacement rationale. ### Crash recovery @@ -51,22 +47,22 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls `Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: source lists are non-empty, unique, earlier, and known; `assistant/message` carries no source list; replacement endpoints exist in surface order; and `sourceEventSeqs` covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions. -Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and current loaded logs. Historical v0 validation and normalization belong to the v0-to-v1 edge rather than generic Session code. +Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and current loaded logs. Released validation and conversion belong to their versioned migration edges rather than generic Session code; see the [V2-to-V3 placement rules](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes). ## Alternatives considered - **Per-plugin `agent/request` wrapping** (the pre-surface pattern for history manipulation) — listener-ordering fragility, no durable record of what was changed, and every new manipulation forces another change to core `deriveMessages()`. -- **Half-open `[start, endExclusive)` replace ranges** — rejected: endpoints are named by surface event seqs, and single-entry replacement (`start === end`) reads naturally with inclusive semantics. +- **Half-open `[start, endExclusive)` replace ranges** — rejected: endpoints are named by surface event seqs, and single-entry replacement (`startSeq === endSeq`) reads naturally with inclusive semantics. - **Linked node objects plus a seq map** — rejected: production did not read predecessor links, the only successor use was the next array position, and replacement already required linear `indexOf` lookup. A single seq array preserves the same asymptotic behavior with one representation to validate. - **Full rebuild behind a dirty flag** instead of delta processing — O(N²) over a session's lifetime: every single-event append would rescan all prior events. ## Consequences - **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). -- **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Each `assistant/message` cites its chunk seqs; each `tool/result` cites its `tool/call` seq. -- **`packages/session/session-persistence-jsonl`**: No changes required. -- **`packages/session/session-persistence`**: Abstract interface unchanged. +- **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Each `assistant/message` embeds its exact provider stream and forbids `sourceEventSeqs`; each `tool/result` cites its `tool/call` seq. +- **`packages/session/session-persistence-jsonl`**: Persists canonical surface metadata and restores current events through validated format preparation. +- **`packages/session/session-persistence`**: Keeps storage ownership separate from the in-memory surface projection. -The surface is the foundation history manipulation ships on — dsh-compaction's compaction rides it. A compaction or tool-result-pruner plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically. +The surface is the foundation history manipulation ships on — dsh-compaction's compaction rides it. A compaction or tool-result-pruner plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', startSeq, endSeq }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically. A `tool/result` replacement may rewrite exactly one current `tool/result` and must preserve every data field except `content`. Session acceptance enforces this rule together with positional range and cited source-event validation, independent of optional diagnostic plugins. diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md index 0596d2a042..54ecb1162b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md @@ -12,36 +12,32 @@ Status: implemented 新增一个 **surface**:事件 seq 的派生并缓存的有序投影(即产出 LLM(大语言模型)消息的事件子集),通过事件日志中的 `surfaceOp` 标记维护。 -### `SessionEvent` 新增两个顶层字段 +### `SessionEvent` 的顶层 surface 元数据 -每个 `SessionEvent` 获得两个可选字段(结构性元数据,与 `seq`/`time` 同级): +surface 元数据仅属于四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`): -- **`sourceEventSeqs?: number[]`**:被引用为数据来源的早期事件 seq 编号,例如 result 引用的 `tool/call`,或被 compaction marker 遮蔽的 surface 节点。出现的列表必须非空、唯一、更早且已知。V2 `assistant/message` 嵌入其 provider stream,不能携带该字段。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。 -- **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。 +- **`sourceEventSeqs?: SessionSeq[]`**:被引用为数据来源的早期事件 seq 编号,例如 result 引用的 `tool/call`,或被 compaction marker 遮蔽的 surface 节点。出现的列表必须非空、唯一、更早且已知。`assistant/message` 嵌入其 provider stream,不能携带该字段。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。 +- **`surfaceOp: SurfaceOp`**:每个 surface 事件必填的位置声明。已知仅日志事件禁止两个元数据字段;原生未知或已退役的可忽略信封保持不透明。 ### SurfaceOp:两种操作 -```ts -export type SurfaceOp = - | 'append' // normal tail append - | { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive -``` +[与源码同步的 `SurfaceOp` 参考](../../../../docs/subsystems/session.zh.md#surface-types)定义了精确联合类型。替换对象仅包含 `op`、`startSeq` 和 `endSeq`;端点使用 `SessionSeq` 品牌。 -1. **Append**:在尾部追加新事件的 seq。`user/message`、`assistant/message`、`tool/result`、`context/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:`tool/result` 记录其 `tool/call` 来源,`assistant/message` 则直接拥有其嵌入式 stream。 +1. **Append**:在尾部追加新事件的 seq。`system/message`、`user/message`、`assistant/message`、`tool/result` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:`tool/result` 记录其 `tool/call` 来源,`assistant/message` 则直接拥有其嵌入式 stream。 -2. **Replace**:移除从 `start` 到 `end`(两端包含)的条目,并在其位置插入新事件的 seq。`start` 和 `end` 都必须存在于当前 surface;`start === end` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。 +2. **Replace**:移除从 `startSeq` 到 `endSeq`(两端包含)的条目,并在其位置插入新事件的 seq。`startSeq` 和 `endSeq` 都必须存在于当前 surface;`startSeq === endSeq` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。 ### SurfaceManager:基于增量,而非全量重建 -一个 `Session` 拥有一个 `SurfaceManager`,后者维护事件 seq 的有序 `number[]`。管理器会在提交前校验每个种子或追加候选项而不应用它,然后只处理上次同步之后已经提交的事件,而不重新扫描整个日志。`Session.surface` 通过只读的 `SessionSurface` 约定暴露同一个管理器,因此接纳、派生历史、压缩与工作区上下文共享同一份增量状态。Replace 按数组位置定位两个端点(均包含在范围内),并把替换 seq splice 到该范围;不会用第二个管理器、链接对象或 seq 到节点的 map 来重复表达顺序。 +一个 `Session` 拥有一个 `SurfaceManager`,后者维护事件 seq 的有序 `SessionSeq[]`。管理器会在提交前校验每个种子或追加候选项而不应用它,然后只处理上次同步之后已经提交的事件,而不重新扫描整个日志。`Session.surface` 通过只读的 `SessionSurface` 约定暴露同一个管理器,因此接纳、派生历史、压缩与工作区上下文共享同一份增量状态。Replace 按数组位置定位两个端点(均包含在范围内),并把替换 seq splice 到该范围;不会用第二个管理器、链接对象或 seq 到节点的 map 来重复表达顺序。 无新事件时增量处理为 O(1),有新事件到达时为 O(新事件数)。 -`deriveMessages()` 在存在 surface 标记时使用 surface,对没有标记的会话回退到既有的线性扫描(向后兼容)。 +`deriveMessages()` 以遍历 surface 作为唯一派生路径。缺少必填标记的 surface 事件无效,不会被视为隐式追加。 ### 持久化 -新字段作为顶层 JSON 属性序列化。JSONL 存储无需单独列映射:其无损 JSON 边界会保留两个值。已发布 v0 与 v1 共享该 surface 表示,恒等的 v0-to-v1 边会精确保留它;未来结构性表示变更会递增 `SESSION_FORMAT_VERSION` 并拥有一项相邻迁移。 +这些字段作为顶层 JSON 属性序列化。JSONL 无需单独列映射即可保留位置与来源。[V3 规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责精确替换键与严格准入依据;[V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)负责历史转换。本文继续负责有序投影的所有权与替换依据。 ### 崩溃恢复 @@ -51,22 +47,22 @@ export type SurfaceOp = `Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs` 与 `surfaceOp`:source list 必须非空、唯一、更早且已知;`assistant/message` 不携带 source list;replacement endpoint 必须存在于 surface 顺序中;`sourceEventSeqs` 必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选 invariant service 提供的规则。 -每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和当前已加载日志。历史 v0 的校验与规范化属于 v0-to-v1 边,而不属于通用 Session 代码。 +每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和当前已加载日志。已发布格式的校验与转换属于各自版本化迁移边,而不属于通用 Session 代码;参见 [V2 到 V3 位置规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)。 ## 曾考虑的替代方案 - **逐插件的 `agent/request` 包装**(surface 之前的历史操纵模式):监听器排序脆弱、无法持久记录改动内容,且每种新操纵都迫使核心 `deriveMessages()` 再次修改。 -- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。端点由 surface 事件 seq 命名,单条目替换(`start === end`)在闭区间语义下读起来更自然。 +- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。端点由 surface 事件 seq 命名,单条目替换(`startSeq === endSeq`)在闭区间语义下读起来更自然。 - **链接节点对象加 seq map**:否决。生产代码不读取前驱链接,唯一的后继用途就是数组中的下一个位置,而替换本来就需要线性 `indexOf` 查找。单个 seq 数组在保留相同渐进复杂度的同时,只留下一个需要校验的表示。 - **脏标记后全量重建**替代增量处理:在会话生命周期内为 O(N²),每次单事件追加都要重新扫描所有先前事件。 ## 后果 - **`packages/core/session`**:`surface.ts`(`SurfaceManager`)维护一个用于候选接纳和实时投影的有序 seq 数组;`SessionSurface` 是其只读公共视图。`SurfaceOp`/`SurfaceIntent` 与顶层会话事件字段记录条目如何加入它。`append()` 要求 surface 事件携带 `SurfaceIntent`,`deriveMessages()` 以遍历 surface 作为唯一派生路径,`repair.ts` 则发出 surface 感知的闭合事件。种子构造函数拒绝缺少 `surfaceOp` 标记的可进入 surface 的种子事件(见「不变式」一节)。 -- **`packages/core/agent-loop`**:所有涉及 surface 事件的追加操作都传入 surface 选项。每个 `assistant/message` 都引用产生它的分片 seq;每个 `tool/result` 都引用它的 `tool/call` seq。 -- **`packages/session/session-persistence-jsonl`**:无需改动。 -- **`packages/session/session-persistence`**:抽象接口不变。 +- **`packages/core/agent-loop`**:所有涉及 surface 事件的追加操作都传入 surface 选项。每个 `assistant/message` 都嵌入精确提供方 stream,并禁止 `sourceEventSeqs`;每个 `tool/result` 都引用其 `tool/call` seq。 +- **`packages/session/session-persistence-jsonl`**:持久化规范 surface 元数据,并通过经过校验的格式准备恢复当前事件。 +- **`packages/session/session-persistence`**:存储所有权与内存 surface 投影保持分离。 -surface 是历史操纵赖以落地的基础——dsh-compaction 的压缩就搭载于其上。压缩或 tool-result-pruner 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽条目的 `sourceEventSeqs`——新事件在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start`、`compaction/end`)不进入 surface。回放以确定性方式保留该决策。 +surface 是历史操纵赖以落地的基础——dsh-compaction 的压缩就搭载于其上。压缩或 tool-result-pruner 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', startSeq, endSeq }` 和覆盖被遮蔽条目的 `sourceEventSeqs`——新事件在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start`、`compaction/end`)不进入 surface。回放以确定性方式保留该决策。 一次 `tool/result` 替换只能改写当前的一个 `tool/result`,并且必须保留除 `content` 以外的每个数据字段。Session 接纳会与位置范围和引用的源事件校验一起强制这条规则,不依赖可选的诊断插件。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 12c82b20bb..9f08a92826 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: bca93a60bf07484d73f1faf50359b72a0d00b9a3 -2026-07-05-reconstructable-requests.zh.md: c9d2a4a5d05456df8b0bd065bade8a41dd7e4e84 +2026-07-05-reconstructable-requests.md: 2f88675a75a72e7fbf105dfbf4f337a4dd80948a +2026-07-05-reconstructable-requests.zh.md: 90008502a6651e38c142b7fb88052c05d46dea76 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index bca93a60bf..2f88675a75 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -22,9 +22,9 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro **Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree. -`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded. +`EpochHeader` records the request's non-history state: call config and tool schemas. Writers omit `tools: []` and `adapterDefaults: {}`; current acceptance rejects those fields and any `header.system`, rather than repairing them. Whitespace-only system-message content, `config.stop: []`, and nested extensions remain intact. The [V3 canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns historical conversion. The rendered system prompt is derived history — the `system/message` event at surface node 0, per the [surface-node Agent Note](2026-09-02-system-prompt-as-surface-node.md) — so a prompt change is a surface replacement rather than a header change. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded. -Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and freezes it while leaving `AbortSignal` live. The [request-freeze provenance decision](../simplification/2026-09-06-agent-request-freeze-provenance.md) owns reuse of completed message freezes and per-request local header freezing. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. +Each proposed step first claims its inbox batch, assembles the system prompt and tools, projects the rendered prompt against the surviving `system/message` node, and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, commits a changed prompt as the `system/message` append or node-0 replacement, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages (system message first) and that header with no `system` field, and freezes it while leaving `AbortSignal` live. The [request-freeze provenance decision](../simplification/2026-09-06-agent-request-freeze-provenance.md) owns reuse of completed message freezes and per-request local header freezing. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. **The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. @@ -49,9 +49,9 @@ Like MiniCode, the conversation advances append-only and resets only when model- - A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. - Model-visible context uses logged message channels. `agent.inject()` and tool `additionalContexts` enter the inbox for a later claim, while `agent/pre-step` returns context that must settle with the current claimed batch. Each entered value is a durable sourced `user/message`, paid once and prefix-cached thereafter at the price of accumulating in history until compaction. -- What still costs full price at the provider is inherent and logged: compaction (its `compaction/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side. +- What still costs full price at the provider is inherent and logged: compaction (its `compaction/*` events and replacement entry), a real prompt change (a `system/message` replacement of surface node 0), a real tool or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side. - `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel. -- Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. +- Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`startSeq === endSeq`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. - Unreadable referenced attachment objects still fail model requests; [automatic attachment quarantine](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) records the proposed recovery without weakening byte-exact reconstruction. -- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full system prompt and tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. Current v1 retains this single representation; the frozen v0-to-v1 edge explicitly refuses legacy delta events before current Session construction. +- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. Current logs retain this single representation; the frozen historical edges explicitly refuse legacy delta events before current Session construction. - Snapshot fixtures include each repeated series header. Keyless refresh owns those deterministic log changes, while the snapshot harness pins prompt and tool sidecars only for the initial and actual change revisions and reuses the current revision for `series` snapshots. Filesystem-writing fixtures remain in normalized authored form with cwd-relative tool arguments because replay only round-trips cwd-independent argument paths. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index c9d2a4a5d0..90008502a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -22,9 +22,9 @@ Status: implemented **消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。 -`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 +`EpochHeader` 记录请求的非历史状态:调用配置和工具 schema。写入方省略 `tools: []` 与 `adapterDefaults: {}`;当前接纳拒绝这些字段以及任何 `header.system`,而不修复它们。仅含空白的系统消息内容、`config.stop: []` 与嵌套扩展保持原样。[V3 规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责历史转换。渲染后的系统提示词是派生历史——surface 第 0 号节点上的 `system/message` 事件,见[surface 节点 Agent Note](2026-09-02-system-prompt-as-surface-node.zh.md)——因此提示词变更是 surface 替换而不是 header 变更。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 -每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结来源证明决策](../simplification/2026-09-06-agent-request-freeze-provenance.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 +每个拟议步骤先领取其 inbox 批次,组装系统提示词与工具,把渲染后的提示词与存活的 `system/message` 节点比对投影,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把变化的提示词作为 `system/message` 追加或第 0 号节点替换提交,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息(系统消息在先)与该不含 `system` 字段的 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结来源证明决策](../simplification/2026-09-06-agent-request-freeze-provenance.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 **已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 @@ -49,9 +49,9 @@ Status: implemented - 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。 - 模型可见上下文使用已记录消息通道。`agent.inject()` 与工具 `additionalContexts` 进入 inbox,等待后续领取;必须与当前已领取批次一起结算的上下文由 `agent/pre-step` 返回。每个进入步骤的值都是带来源的持久 `user/message`,只付出一次代价并在后续成为可缓存前缀,代价是会在历史中累积直至压缩。 -- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 +- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词变更(对 surface 第 0 号节点的 `system/message` 替换)、真正的工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 - `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。 -- 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。 +- 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`startSeq === endSeq`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。 - 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)记录了不削弱字节精确重建的拟议恢复方案。 -- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整系统提示词与工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。当前 v1 保留这一种表示;冻结的 v0-to-v1 迁移边会在构造当前 Session 前显式拒绝旧版 delta 事件。 +- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。当前日志保留这一种表示;冻结的历史迁移边会在构造当前 Session 前显式拒绝旧版 delta 事件。 - 快照 fixture 包含每个重复的 series header。无密钥 refresh 负责这些确定性日志变化;快照 harness 只为 initial 与真实 change 修订固定提示词和工具 sidecar,并让 `series` 快照复用当前修订。写入文件系统的 fixture 继续以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 51abb8fd86..aff7b32029 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.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-08-agent-scope-contexts.md -2026-07-08-agent-scope-contexts.md: 6a1fd4aed49cb8edef061c8fb6f0edcd0a09c30f -2026-07-08-agent-scope-contexts.zh.md: 8408c4afff6075c129c6a96c47393c9c812b04b7 +2026-07-08-agent-scope-contexts.md: 45e635b7bc3138d4e90a25a06ff23b3b57a9415e +2026-07-08-agent-scope-contexts.zh.md: aac860734e744843c3b9e7d55e5bc7a150763090 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md index 6a1fd4aed4..45e635b7bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -16,6 +16,8 @@ The mechanism also needs a publication boundary. An agent must not become visibl Every live agent owns one flat registration layer exposed as `agent.ctx`. Code registers through the context that owns a contribution; scope-aware services combine deployment-global registrations with exactly one matching agent layer; operations choose that layer from their real agent; and the layer exists for the agent's complete published lifetime. +`agent.ctx` carries registration ownership and the scope key; it does not expose a reverse `agent` property. Code that needs the domain subject receives it explicitly: `AgentSetup` receives `(agentCtx, agent)`, and scoped events carry their subject in the payload. + Cordis is the plugin framework underneath the SDK. A Cordis **context** is the object plugins use to access services and register effects whose cleanup follows that context. The [Cordis primer](../../../../docs/cordis-primer.md) explains the framework in more detail. For most contributors, the complete contract is four rules: @@ -45,7 +47,7 @@ flowchart LR The missing cross-edges are the isolation rule: Agent A's local registrations do not enter Agent B's view, and a parent's registrations do not enter a child merely because the parent owns the child's lifetime. -The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature. +The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [explicit runtime-identity Agent Note](2026-08-31-explicit-agent-runtime-identity.md) owns why lifecycle, event, and transport interfaces pass Agent identity instead of exposing it through Context. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature. ### Registration origin chooses visibility and cleanup @@ -88,7 +90,7 @@ await handle.dispose() ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone ``` -Setup receives a full trusted Cordis context so it can compose ordinary plugins and services. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported. +Setup receives the full trusted Cordis context and unpublished Agent so it can compose ordinary plugins and services while reading the exact child Session when needed. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported. ### The operation chooses the view diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index 8408c4afff..aac860734e 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -16,6 +16,8 @@ Status: implemented 每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 +`agent.ctx` 携带注册所有权和作用域键,不暴露反向的 `agent` 属性。需要领域主体的代码会显式接收它:`AgentSetup` 接收 `(agentCtx, agent)`,作用域事件则在 payload 中携带主体。 + Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.zh.md)对该框架有更详细的说明。 对大多数贡献者而言,完整约定是四条规则: @@ -45,7 +47,7 @@ flowchart LR 缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。 -配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 +配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md)阐述实现与正确性推理。[显式运行时身份 Agent Note](2026-08-31-explicit-agent-runtime-identity.zh.md)说明生命周期、事件和传输接口为何显式传递 Agent 身份,而不通过 Context 暴露该身份。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md)负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 ### 注册来源决定可见性与清理 @@ -88,7 +90,7 @@ await handle.dispose() ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone ``` -setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插件和服务。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。 +setup 接收完整的受信 Cordis 上下文和未发布的 Agent,因此既可以组合普通插件和服务,也能在需要时读取确切的子 Session。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。 ### 操作选择视图 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 3c883f7d6c..d03d20b03b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 5533c38d635d04c8799658fc1f6bcec8543d1dab -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 05800082034f0d1bd5034fc5bb17f09356d84dd9 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7792e24a5869be6b7bae7787a6a481f3075ba740 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 857bfaec80da962fac0403f2239e0a5e71954194 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 5533c38d63..7792e24a58 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -66,7 +66,7 @@ The Python client launches the packaged `dsh` command with the selected profile ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and single shell-tool catalog and retains shell state across calls. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 0580008203..857bfaec80 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -66,7 +66,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread` ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与单个 shell 工具目录,并跨调用保留 shell 状态。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index e469f71c7b..dbcffa137d 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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-12-agent-scope-runtime-design.md -2026-07-12-agent-scope-runtime-design.md: b6001a5ef9f2dc69ec21908f8350b765dd00acf1 -2026-07-12-agent-scope-runtime-design.zh.md: be12c53ffa9b89e007888935002a5c484c038fd7 +2026-07-12-agent-scope-runtime-design.md: ca300d4eeeab878a4e41b8e68a669be418617181 +2026-07-12-agent-scope-runtime-design.zh.md: 870690d6ace9fefd859557a2e73e88b9b1da6206 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index b6001a5ef9..ca300d4eee 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -42,6 +42,8 @@ All agents share one Cordis service graph. A derived context does not clone `Too `agent.ctx` is such a derived context. Service calls still reach the shared instances, while a registration can inspect its calling context and store a contribution under the nearest scope key. Ordinary plugin contexts carry no scope key and therefore register globally. +The Agent context is exactly the context returned by `createScope`; it carries no second reverse association to the Agent. Subject-bearing APIs pass the Agent explicitly, leaving one formal scope mechanism for registration ownership and routing. + ### Fibers and effects make cleanup structural A Cordis fiber is the live instance created when a plugin or child context is activated. Its state records whether that lifecycle is active, unloading, failed, or disposed. `ctx.effect()` and `ctx.on()` return disposers and also attach those disposers to the registering fiber, so unloading a plugin or agent scope removes everything registered through that context without a separate inventory. @@ -68,7 +70,7 @@ A `ScopeKey` is an opaque object compared by identity. The harness uses the live `createScope(parent, key)` returns a scope whose `ctx` shares the parent's services and whose effects are tagged with that key. `scopeOf(ctx)` reads the nearest registration key. `scopeTarget(base, key)` creates the event receiver whose filter preserves the base receiver's Cordis service filter, then admits unscoped listeners and listeners with that exact key. -The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`. +The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives an explicit setup parameter or event argument; code that needs registration ownership receives `agent.ctx`. ### Registry reads overlay one exact layer @@ -100,11 +102,11 @@ The transaction is installed under both the calling Cordis context and the concr Create prepares a new Session. Resume loads and validates the persisted Session before preparing the same live session identity. Both paths then build the scope, agent, and driver and invoke the same setup/publication algorithm. -The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. This preserves dependency origin and caller ownership without stacking trace proxies. +The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. A runtime child creator sets `parentAgent` in the create or resume options, and AgentRegistry forwards those options without deriving a parent from the caller Context. This preserves dependency origin and both ownership facts without stacking trace proxies or attaching a domain object to the Context. Scoped Remote event adapters likewise receive the Agent in the request, verify that it is the carrier key, and project its Context and wire identity directly. No scope index reconstructs an Agent from a Context. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) owns this separation and the continuable-child ownership rule that follows from it. ### Setup is trusted composition inside a private world -Setup receives the full child context and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, but the public contract does not support driving or publishing the in-flight agent through casts or internal registry calls. +Setup receives the full child context and the exact unpublished Agent, and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, and consumers that need the child's Session read it from the Agent parameter. The public contract does not support driving or publishing the in-flight agent through casts or internal registry calls. The transaction races asynchronous load and setup against deactivation rather than waiting forever for a promise owned by external code. If cancellation or owner unload wins, public creation rejects after transaction-owned cleanup even when the external promise never settles. diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index be12c53ffa..870690d6ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -42,6 +42,8 @@ Status: implemented `agent.ctx` 就是这样一个派生上下文。服务调用仍然到达共享实例,而注册操作可以检查其调用上下文并将贡献存储在最近的作用域键下。普通的插件上下文不携带作用域键,因此注册到全局。 +Agent 上下文就是 `createScope` 返回的上下文,不携带第二份指回 Agent 的关联。需要主体的 API 显式传递 Agent,因此注册所有权与路由只依赖一种正式的作用域机制。 + ### Fiber 与 effect 使清理成为结构性的 Cordis fiber 是插件或子上下文被激活时创建的活跃实例。其状态记录该生命周期是 active、unloading、failed 还是 disposed。`ctx.effect()` 和 `ctx.on()` 返回 disposer,同时将这些 disposer 附加到注册所在的 fiber,因此卸载一个插件或 agent 作用域会移除通过该上下文注册的一切,无需单独的清单。 @@ -70,7 +72,7 @@ scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个 `createScope(parent, key)` 返回一个作用域,其 `ctx` 共享父级的服务,其 effect 被标记为该键。`scopeOf(ctx)` 读取最近的注册键。`scopeTarget(base, key)` 创建事件接收器,其过滤器保留 base receiver 的 Cordis 服务过滤器,然后接纳无作用域的监听器和具有该确切键的监听器。 -Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。 +Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的 setup 参数或事件参数;需要注册所有权的代码接收 `agent.ctx`。 ### 注册表读取叠加一个精确 layer @@ -102,11 +104,11 @@ detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册 创建准备一个新 Session。恢复加载并验证持久化的 Session,然后准备相同的活跃会话标识。两条路径随后构建作用域、agent 和 driver,并调用相同的 setup/发布算法。 -工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。这保留了依赖来源和调用方所有权,而不堆叠 trace 代理。 +工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。运行时子 Agent 的创建方在 create 或 resume options 中设置 `parentAgent`,AgentRegistry 转交这些 options,不从调用方 Context 推导父级。这既保留了依赖来源和两种所有权事实,又不堆叠 trace 代理,也不把领域对象附着到 Context。作用域 Remote 事件适配器同样从 request 接收 Agent,校验它就是 carrier key,再直接投影其 Context 与 wire identity。系统不会通过作用域索引从 Context 重建 Agent。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)拥有这项分离原则及由此确定的可续跑子级归属规则。 ### Setup 是私有世界内的可信组合 -Setup 接收完整的子上下文,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect,但公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。 +Setup 接收完整的子上下文和确切的未发布 Agent,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect;需要子 Session 的消费者从 Agent 参数读取它。公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。 事务将异步加载和 setup 与停用进行竞争,而非无限等待外部代码拥有的 promise。如果取消或所有者卸载获胜,即使外部 promise 永不结算,公开创建也会在事务拥有的清理之后拒绝。 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index d757a5efd0..06c136e20b 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.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-14-provider-routed-llm-adapters.md -2026-07-14-provider-routed-llm-adapters.md: 4cc3cc3cdceeefdfea864ccf3465e528e32159c7 -2026-07-14-provider-routed-llm-adapters.zh.md: 098a6af7c951c6dee8a1b4f948bc69725e8d189c +2026-07-14-provider-routed-llm-adapters.md: 24d6e7e439dc74a158801b647ddac96169730af1 +2026-07-14-provider-routed-llm-adapters.zh.md: f740468ed67830dabf5769eca206402d91c90aac diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index 4cc3cc3cdc..24d6e7e439 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -30,7 +30,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek` `dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step extension point. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback. -The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog. +The plugin registers configured provider names against one `PiAiAdapter` in one atomic call. Each immutable request snapshot combines the effective profiles and their serviceable model descriptors. Catalog-external models require an explicit or inferable protocol and endpoint. Stored catalog errors remain visible and repairable under the [settings catalog recovery decision](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md), while writes validate changed providers and requests reject the selected failed model before network I/O. The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its registered API implementation, including OpenAI Responses instead of Chat Completions where the descriptor says `openai-responses`. Harness temperature, maximum tokens, signal, session id, and the profile's common stream options flow through directly. Profile headers merge with the mandatory Harness attribution headers, with Harness attribution winning its reserved names. The adapter no longer maintains DeepSeek-specific payload rewrites or a provider-protocol matrix. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 098a6af7c9..f740468ed6 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -30,7 +30,7 @@ Status: implemented `dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤扩展点上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 -插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。 +插件通过一次原子调用,将已配置的提供方名称注册到同一个 `PiAiAdapter`。每个不可变请求快照组合有效 profile 与可服务模型的描述符。目录外模型需要显式指定或可推断的协议与端点。根据[设置目录恢复决策](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md),已存储的目录错误保持可见、可修复,写入仍会校验已修改提供方,请求则在网络 I/O 前拒绝所选错误模型。 适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。Harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 Harness 强制归因 headers 合并;发生保留名称冲突时,以 Harness 归因为准。适配器不再维护 DeepSeek 专用 payload 重写或提供方协议矩阵。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 570b68263c..99577f250e 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.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-15-agent-initiator-scope.md -2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277 -2026-07-15-agent-initiator-scope.zh.md: 3ea893aa5f6992bf09965436c1db3144d2fae5ac +2026-07-15-agent-initiator-scope.md: ab11da116a463cd706418e797eb58f1bc4ab9b1c +2026-07-15-agent-initiator-scope.zh.md: 343acba5f99379b6d2c3af41368e8fbe90b20611 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md index 63540c0ec6..ab11da116a 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md @@ -6,7 +6,7 @@ English | [中文](2026-07-15-agent-initiator-scope.zh.md) ## Problem -The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. +The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. A dynamic `ctx.agent` meaning “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. Deep process-local infrastructure sometimes needs a trusted initiating Agent below explicit loop, tool, and request parameters—for example, a host-aware transport, tracing helper, logger, or gateway client. Requiring every private helper to forward `agent` adds repetition, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are unsuitable because a model must not choose a trusted Session or routing header. The carrier belongs to the Agent service rather than optional model-visible context. @@ -18,9 +18,9 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in `AgentLoop` already injects `ctx.agents` and wraps each concrete driver's complete `runLoop` lifetime in `agents.withInitiator(agent, ...)`. Its package-private loop, turn, step, and tool-call orchestration entries recover the exact Agent from `ctx.agents`, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or `Session` through shallow interfaces. A leaf helper keeps a narrow `Session` parameter when that is its actual interface rather than accepting a broader `Context` only for an ambient lookup. -Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child. +Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx, childAgent)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while the explicit `childAgent` parameter identifies the child. -Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, job ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. +Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, the Agent parameter of `AgentSetup`, `GenerateOptions.sessionId`, job ownership, parent/child requests, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. `AgentRegistry` owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing `ctx.agents` then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. If a boundary's inherited async chain starts an owning Cordis fiber's unload, the private run-token lineage releases that nested boundary chain from the drain, which prevents teardown from waiting on itself while unrelated boundaries still drain. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while the ordinary drain runs; after disposal, initiator methods throw `agent initiator scope is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering. @@ -28,7 +28,7 @@ Initiator scope does not own detached work: registry drain tracks only the Promi A host-aware transport may derive a deployment-owned header such as `X-Harness-Session-Id` from `ctx.agents.requireInitiator().session.id`; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam. -This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. +This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) keeps initiator scope limited to private asynchronous chains while lifecycle, ownership, event, and wire interfaces carry their subjects directly. ## Verification @@ -40,7 +40,7 @@ A test-double host-aware transport derives `X-Harness-Session-Id` internally and **Pass Agent through every function.** Public, worker, process, persistence, and wire boundaries continue to do this, but requiring every process-local private helper to carry Agent adds repetitive forwarding without improving trust. ALS is confined to the asynchronous chain inside those explicit boundaries. -**Make `ctx.agent` dynamic.** `ctx.agent` already means the static Agent associated with an Agent-scoped Cordis context. Changing the root meaning would mix registration and execution scopes and make concurrent behavior surprising. +**Expose a dynamic `ctx.agent`.** Context carries registration ownership, not a domain subject. Adding an accessor for the executing Agent would mix registration and execution scopes and make concurrent behavior surprising. **Add a separate `ctx.agentExecution` service.** The carrier has no independent backend, configuration, or identity type: it stores the same `Agent` that `ctx.agents` already owns, and AgentLoop already depends on that service. A second mandatory provider would add package, composition, lifecycle, generated-catalog, and test-harness wiring without separating a real capability. diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md index 3ea893aa5f..343acba5f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 +harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若提供表示「当前正在运行的 Agent」的动态 `ctx.agent`,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体归 Agent 服务所有,而非模型可见的可选上下文。 @@ -18,9 +18,9 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 `AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。循环、轮次、步骤和工具调用的包内私有入口从 `ctx.agents` 恢复同一个 Agent,一次推导 `agent.session`,再由操作内辅助函数捕获该值,避免在浅层接口中转发具体驱动或 `Session`。若 `Session` 本身就是底层辅助函数的实际接口,该函数会保留狭窄的 `Session` 参数,而不会只为隐式查找而接收更宽泛的 `Context`。 -因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 +因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx, childAgent)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而显式的 `childAgent` 参数标识子 Agent。 -隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 +隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`AgentSetup` 的 Agent 参数、`GenerateOptions.sessionId`、任务归属、父子请求、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 `AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose(资源释放)后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。 @@ -28,7 +28,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 -本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。 +本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)把发起方作用域限制在私有异步调用链内,同时让生命周期、归属、事件和协议接口直接携带各自的主体。 ## 验证 @@ -40,7 +40,7 @@ Agent 服务测试锁定可选与必需读取、同步值及跨 realm Promise **在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会造成重复转发,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。 -**让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。 +**暴露动态的 `ctx.agent`。** Context 携带注册所有权,而非领域主体。为正在执行的 Agent 新增 accessor 会混合注册作用域与执行作用域,并让并发行为变得意外。 **新增独立的 `ctx.agentExecution` 服务。** 该载体没有独立后端、配置或身份类型:它存储的是 `ctx.agents` 已经管理的同一个 `Agent`,而 AgentLoop 本就依赖该服务。第二个必需提供方会增加包、组合、生命周期、生成目录及测试 harness 接线,却没有拆出真实能力。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml index a6c2c93f3d..cf9b3216dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.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-20-canonical-tool-output-contract.md -2026-07-20-canonical-tool-output-contract.md: f2c17325f77b93675086c39dd5a7693854b8521a -2026-07-20-canonical-tool-output-contract.zh.md: d81c1730aab66df2dcf4eea4515f260df917ffb4 +2026-07-20-canonical-tool-output-contract.md: 4dbcac3da8381e69809b15a653cdb4987af4e0e7 +2026-07-20-canonical-tool-output-contract.zh.md: c2f4ebdfc7266495570766c69b3aa264f91172cc diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md index f2c17325f7..4dbcac3da8 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -34,7 +34,7 @@ type ToolExecutionResult = `tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value. -Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata. The Client can derive [nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) from raw arguments and rendered content without that metadata. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. +Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/ptc-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata. The Client can derive [nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) from raw arguments and rendered content without that metadata. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. The first-party tools preserve their existing Native text while returning domain DTOs: diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md index d81c1730aa..c2f4ebdfc7 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -34,7 +34,7 @@ type ToolExecutionResult = `tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。 -规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据。Client 可以从原始参数与渲染后的内容派生[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md),无需这些元数据。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 +规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/ptc-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据。Client 可以从原始参数与渲染后的内容派生[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md),无需这些元数据。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 第一方工具在保持现有 Native 文本不变的同时返回领域 DTO: 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 7d108bbf7d..f633a831d3 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: 0e493a19e232acf6f289714e53e0cf64b3ffc4b0 -2026-07-22-slot-type-chain-implementation.zh.md: 5e5c2ec808cec6949396a333c6a86e267bb4268b +2026-07-22-slot-type-chain-implementation.md: 98585a42f71844594da4ada19d7438a7d8321702 +2026-07-22-slot-type-chain-implementation.zh.md: d1093537ccbc5ee5ff2dc18c234233ffca39879d 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 0e493a19e2..98585a42f7 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 @@ -12,6 +12,8 @@ The page is composed at runtime from independently loaded plugins, so the UI nee ## Decision +Global main-panel selection and its root lifetime are defined by the [global main-panels decision](2026-09-08-global-main-panels.md). + One sentence: **the ui-renderer renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.** ### 'root' is the only a-priori slot @@ -25,7 +27,7 @@ ctx.slots.register({ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' }, - 'conversation': { kind: 'single', scope: 'session' }, + 'main': { kind: 'keyed', scope: 'root' }, }, store: createLayoutStore, // StoreHandle or factory (below) inject: injectFrame, // business face (below) 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 5e5c2ec808..d1093537cc 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 @@ -12,6 +12,8 @@ Status: implemented ## 决策 +全局主面板选择及其 root 生命周期由[全局主面板决策](2026-09-08-global-main-panels.zh.md)定义。 + 一句话:**ui-renderer 只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。** ### 'root' 是唯一的先验 slot @@ -25,7 +27,7 @@ ctx.slots.register({ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' }, - 'conversation': { kind: 'single', scope: 'session' }, + 'main': { kind: 'keyed', scope: 'root' }, }, store: createLayoutStore, // StoreHandle or factory (below) inject: injectFrame, // business face (below) diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml new file mode 100644 index 0000000000..4fac92ed8c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.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-31-explicit-agent-runtime-identity.md +2026-08-31-explicit-agent-runtime-identity.md: f52b8ec116c312a27306fe73dc0bd5b99fcd9039 +2026-08-31-explicit-agent-runtime-identity.zh.md: 6b6fd2f2ea1f1645069264f09fd53c3f71e1d02f diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md new file mode 100644 index 0000000000..f52b8ec116 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -0,0 +1,47 @@ +# Agent Note: Explicit Agent identity at runtime boundaries + +Status: implemented + +English | [中文](2026-08-31-explicit-agent-runtime-identity.zh.md) + +## Problem + +An Agent's Cordis Context owns registrations and their cleanup. Agent identity instead selects the Session, runtime owner, event subject, authority decision, or wire identity for one operation. A reverse Agent property on Context made those two facts appear interchangeable: a caller could choose a Context for effect ownership and accidentally let that choice determine domain identity. + +The reverse association also required compensating mechanisms after type erasure. Host Remote forwarding inspected a routed subject for its Context, creation inferred runtime parentage from the caller Context, and adapters maintained reverse identity scans. These mechanisms duplicated identity already present in typed requests and obscured which caller owned an Agent at runtime. + +Without an explicit owner, `SubagentContinuationManager` creates and resumes children through its private plugin Context, so Context-based inference classifies every continuable child as a runtime root even though the manager holds its exact parent. Root-only consumers could then attach scheduling tools, grant direct-human goal authority, or route user questions as if the child were top-level. + +## Decision + +Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. + +Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. + +`SubagentContinuationManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. + +The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its [runtime design](2026-07-12-agent-scope-runtime-design.md), and the [initiator-scope decision](2026-07-15-agent-initiator-scope.md) retain their independent registration, lifecycle, and private-chain rationale. This decision supersedes only the reverse Context association and implicit runtime-owner derivation described there. + +## Verification + +Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. + +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance. + +## Alternatives considered + +**Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup. + +**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must put the parent it already knows in the request options. + +**Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs. + +**Use the initiating Agent as creation ownership.** Initiator scope records causal asynchronous execution, not lifetime ownership. A parent may initiate work that intentionally creates a root, and setup remains outside the child's driver boundary. + +**Use durable Session lineage.** `parentSession` records conversation ancestry across process lifetimes. Runtime ownership controls live roots and teardown, so equating the two would prevent a legitimately resumed fork from becoming a top-level Agent. + +## Consequences + +Lifecycle options, events, service requests, and transport requests carry explicit Agent identities, so each operation states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator. + +Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md new file mode 100644 index 0000000000..6b6fd2f2ea --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 运行时边界显式携带 Agent 身份 + +Status: implemented + +[English](2026-08-31-explicit-agent-runtime-identity.md) | 中文 + +## 问题 + +Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操作选择会话、运行时所属方、事件主体、权限决策或协议身份。Context 上反向的 Agent 属性让这两个事实看起来可以互换:调用方选择用于管理 effect 所有权的 Context 时,可能意外地让该选择决定领域身份。 + +类型信息被擦除后,这项反向关联还需要补偿机制。Host Remote 转发会从已路由主体检查其 Context,创建流程会从调用方 Context 推断运行时父级,适配器则维护反向身份扫描。这些机制重复类型化请求中已有的身份,也掩盖了哪个调用方在运行时拥有 Agent。 + +若没有显式所属方,`SubagentContinuationManager` 会通过私有插件 Context 创建和恢复子级,因此基于 Context 的推断会把每个可续跑子级归类为 runtime root,尽管管理器持有其确切父级。仅限根级的消费方随后可能附加调度工具、授予直接人类输入对应的 Goal 权限,或像处理顶层 Agent 一样路由用户问题。 + +## 决策 + +运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 + +感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 + +`SubagentContinuationManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 + +[Agent 注册作用域决策](2026-07-08-agent-scope-contexts.zh.md)、其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md)和[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)继续拥有各自独立的注册、生命周期及私有调用链理由。本决策只取代其中描述的反向 Context 关联和隐式运行时所属方推导。 + +## 验证 + +Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 + +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 + +## 考虑过的替代方案 + +**保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。 + +**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须把它已知的父级放进请求 options。 + +**分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。 + +**使用发起 Agent 作为创建归属。** 发起方作用域记录异步执行的因果关系,而非生命周期归属。父级可能发起有意创建根级 Agent 的工作,而 setup 仍位于子级驱动边界之外。 + +**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录对话祖先关系。运行时归属控制存活根级和 teardown,因此把二者等同会阻止合法恢复的 fork 成为顶层 Agent。 + +## 后果 + +生命周期 options、事件、服务请求和传输请求会携带显式 Agent 身份,因此每项操作都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。 + +可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index e25084a26f..e8b41ca73b 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 592322c0e4c1b2fa52dcf71652f3878f43a8c8ca -2026-08-31-released-session-format-migrations.zh.md: ba2317903845739cda8da1c01c2f959c7a2ccd50 +2026-08-31-released-session-format-migrations.md: 286737730198ad895de2f03a48f9c74848839582 +2026-08-31-released-session-format-migrations.zh.md: 228b09e03916c1fa447398edb45508a7a6ab61e5 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 592322c0e4..2867377301 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -58,16 +58,33 @@ JSONL record → released physical row decoder → v0-to-v1 stage → v1-to-v2 stage + → v2-to-v3 stage → current event collector ``` The chain contains no `flatMap`, spread expansion, intermediate event array, or scheduler. The final event collector expands a compact run only after every migration stage has had the opportunity to consume it directly. +### Adjacent version ownership + +The [V2-to-V3 delivery guards](../../../../packages/session/session-format-v2-to-v3/README.md#delivery-guards) prevent a marker ignored in the source generation from becoming an active upload watermark merely because the header changes. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer. + +The [V2-to-V3 README](../../../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is the single specification for that edge's transformations, preservation, and refusal; its separate [native admission section](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission) prevents current-only capabilities from being mistaken for historical transformations. The released V2 codec remains owned by V1→V2 and is reused, not copied. The [system-prompt](2026-09-02-system-prompt-as-surface-node.md), [PTC](../feature/2026-06-15-ptc.md), and [canonical-envelope](2026-09-06-v3-canonical-session-envelopes.md) notes retain their independent rationale, not duplicate conversion specifications. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands. + +Historical content admission belongs to the incoming edge, not native V3 extension validation. Preserving an unknown block without understanding its fields cannot establish that migration preserves its meaning. The [source audit](../../../../packages/session/session-format-v2-to-v3/README.md#source-audit) therefore uses one historical kind set across its explicitly owned content positions, including partial streams. It inspects admitted content without rewriting it and leaves owner-opaque JSON uninterpreted. Narrowing native acceptance or editing frozen predecessor validators would change independent promises rather than establish safe conversion. + +Preset renames cover the creation header and every selection event because the latest selection controls resume while earlier selections control historical forks. Rewriting only the last selection loses that distinction. The released `code` id denotes the legacy built-in preset; migration is independent of the installed roster so the same bytes produce the same result on every host. Native V3 custom ids remain available without a global runtime alias. + +A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array. + +All structural changes compose in the one unreleased V2→V3 edge; feature or review order does not allocate extra Session format versions. V0, V1, and V2 generations remain byte-frozen, and migration publishes only the final V3 successor. The unreleased target can evolve until release, but an already-written V3 file does not rerun its incoming migration. Integration tests therefore require isolated disposable homes and unchanged historical inputs rather than rewriting committed generations. + +The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader. + ### Physical codecs and packed runs Each released codec creates a row decoder with explicit `strict` or `recoverable` recovery. The decoder validates and emits one event or one codec-owned `SessionFormatEventRun` at a time through separate context methods. v0-to-v1 and v1-to-v2 implement both `transformEvent()` and `transformRun()`, so packed Assistant chunks can reach the folding edge without first becoming millions of ordinary events. -The v0-to-v1 edge preserves logical headers, sequence numbers, references, timestamps, and payloads except for bounded released-v0 normalizations. It translates the retired `steering/message` and `compact/*` event names, accepts a released `llm/retry` after its matching `step/end`, deterministically supplies a missing `llm/retry.retryId` per turn/step/provider/policy chain, and supplies one deterministic `compactionId` across a legacy compaction group that omitted it. The v1-to-v2 edge owns attempt folding and reference remapping, and emits only settled current events. It splits a legacy goal-sourced user message into `goal/change` plus the original model-visible message. It also inserts an interrupted `turn/end` for the bounded released restart in which an open turn with no open step is followed by a non-empty `next-turn` inbox splice and the next numbered `turn/start`. +The v0-to-v1 edge preserves logical headers, sequence numbers, references, timestamps, and payloads except for bounded released-v0 normalizations. It translates the retired `steering/message` and `compact/*` event names, accepts a released `llm/retry` after its matching `step/end`, deterministically supplies a missing `llm/retry.retryId` per turn/step/provider/policy chain, and supplies one deterministic `compactionId` across a legacy compaction group that omitted it. The v1-to-v2 edge owns attempt folding and reference remapping, and emits only settled v2 events. It splits a legacy goal-sourced user message into `goal/change` plus the original model-visible message. It also inserts an interrupted `turn/end` for the bounded released restart in which an open turn with no open step is followed by a non-empty `next-turn` inbox splice and the next numbered `turn/start`. The catalog exposes one `createRestore()` operation for production, Worker, fixture, and replay callers. Recovery policy and final validation policy are chosen once at restore creation. Historical production uses recoverable source parsing with transformed-current validation; this validates the released current result after migration, while input that is already current receives only codec validation. Worker and fixture verification use strict parsing with full installed current restoration. A migration-stage or transformed-current validation refusal remains `SessionFormatUnsupportedMigrationError`; physical decoding failures remain corruption. Test support keeps only fixture-specific token and envelope materialization. @@ -77,6 +94,8 @@ The JSONL provider scans frame boundaries once, reuses one Zstandard decoder, pa Current encoding is record based. The provider serializes about 1 MiB of plaintext per main-thread slice, streams it through one Zstandard context with source-error propagation, writes compressed output in 4 MiB batches to an exclusively created same-directory temporary file, and syncs it before publication. A process-wide scheduler admits at most two full verification Workers and hands a released permit directly to the oldest waiter. +The shipped `lib/worker.cjs` bundles its JavaScript workspace dependencies so each fresh verifier avoids resolving and compiling their runtime module graph. This is safe because the Worker communicates through plain request/result messages and shares no service or class identity with its host. The Host build applies the existing TypeScript and Typert transforms; the Client pass skips this Node-only package instead of replacing its worker with untransformed source. Native add-ons remain external. Verification, scheduler admission, termination, and durable publication still complete before writable open returns. The built-worker smoke copies the package manifest and worker into an isolated temporary package with ambient module paths removed, accepts a valid generation, and rejects an incorrect event count. + Preparation forwards cancellation through source reads and observes it at the existing approximately 500 ms Decode yield boundary. Once `publish()` starts, encode, Worker verification, and publication do not receive caller cancellation and run to settlement; write open checks its caller signal again afterward. A published generation is never rolled back. The Stage pipeline ends at one prepared current artifact. [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access. @@ -105,6 +124,10 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr ## Verification +The migration specification requires evidence for transformations, preservation, and refusal separately. Direct-edge and native V3 tests cannot establish seeded multi-hop publication: preceding assistant-stream folding changes source coordinates before V3 inserts system events. Tests through the real catalog and JSONL provider therefore need raw and compressed V0/V1 inputs, mapped references and inherited cuts, publish/reopen equivalence, unchanged predecessor bytes, and no intermediate generations. Coverage percentages alone cannot prove those cross-stage relationships; combined assertions must compare the resulting history and refusal effects. + +Content-admission evidence must cover every position named in the specification, nested results, partial starts, and malformed known blocks, with source-coordinate diagnostics. Successful migration must preserve admitted content and opaque values. Refusal through real persistence must leave the source unchanged and publish no successor. Native V3 tests must independently retain extension acceptance under both catalog validation policies; historical refusal is not evidence of native rejection. + ### Benchmark input and meanings The benchmark uses Node v24.18.0 and one 116,228,655-byte v0 Zstandard log containing 317,540 frames and 454,151 physical rows. The old reader restores 9,143,111 expanded v0 events. Migration produces 72,784 current v2 events with artifact SHA-256 `fa16ff9472ca350595a3112c20a3db79655bc2673973469987ecaf2a57ebd17c`. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index ba23179038..228b09e039 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -58,16 +58,33 @@ JSONL record → released physical row decoder → v0-to-v1 stage → v1-to-v2 stage + → v2-to-v3 stage → current event collector ``` Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 scheduler。只有在每个 migration stage 都已获得直接消费 compact run 的机会后,最终 event collector 才会展开它。 +### 相邻版本所有权 + +[V2 到 V3 投递保护](../../../../packages/session/session-format-v2-to-v3/README.zh.md#delivery-guards)防止源代中被忽略的标记仅因头部变化就成为有效上传水位。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。 + +[V2 到 V3 README](../../../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)是该迁移边转换、保留与拒绝规则的单一规范真源;单列的[原生准入章节](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)避免将仅当前版本支持的能力误认为历史转换。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。[系统提示词](2026-09-02-system-prompt-as-surface-node.zh.md)、[PTC](../feature/2026-06-15-ptc.zh.md)和[规范信封](2026-09-06-v3-canonical-session-envelopes.zh.md)记录保留各自独立依据,而非重复转换规范。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。 + +历史内容准入归入边所有,而非原生 V3 扩展校验。在不了解字段的情况下保留未知块,不能证明迁移保留了其含义。因此,[源审计](../../../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)在明确归其所有的内容位置(包括未完成的流)使用同一历史种类集合。它检查已接纳的内容而不改写,并且不解释归其他所有者所有的不透明 JSON。收紧原生准入或修改冻结的前代校验器,会改变独立承诺,而非证明转换安全。 + +预设更名覆盖创建头部和每条选择事件,因为最新选择决定恢复时的预设,而更早的选择决定历史 fork 的预设。只改写最后一条选择会丢失这种区别。已发布的 `code` 标识表示旧内置预设;迁移不依赖已安装的预设列表,因此相同字节在每台主机上产生相同结果。原生 V3 的自定义标识仍可使用,无需全局运行时别名。 + +源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。 + +所有结构变更组合在唯一且尚未发布的 V2→V3 迁移边中;功能或评审顺序不分配额外 Session 格式版本。V0、V1、V2 代际保持字节冻结,迁移只发布最终 V3 后继代际。未发布的目标可以持续演化至发布,但已经写出的 V3 文件不会重新执行入边迁移。因此,集成测试必须使用隔离、可丢弃的 home 和未变更的历史输入,而非改写已提交代际。 + +[已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。 + ### Physical codec 与 packed run 每个 released codec 会用显式 `strict` 或 `recoverable` 策略创建 row decoder。Decoder 每次通过不同的 context 方法校验并 emit 一个 event 或 codec-owned `SessionFormatEventRun`。v0-to-v1 与 v1-to-v2 都实现 `transformEvent()` 和 `transformRun()`,因此 packed Assistant chunk 可以直接到达 folding edge,无需先变成数百万个普通事件。 -v0-to-v1 除了有限的 released-v0 归一化外,会保留逻辑 header、seq、引用、时间戳与 payload。它转换已移除的 `steering/message` 与 `compact/*` 事件名称,接受出现在对应 `step/end` 之后的已发布 `llm/retry`,按 turn/step/provider/policy chain 为缺失的 `llm/retry.retryId` 确定性补值,并为省略 id 的旧 compaction group 确定性补充同一个 `compactionId`。v1-to-v2 负责 attempt folding 与引用重写,并且只 emit 已结算的 current event。它会把旧的 goal 来源 user message 拆成 `goal/change` 与原本的模型可见 message。它还会为一种有限的已发布 restart 插入 interrupted `turn/end`:一个没有 open step 的 open turn 后出现非空 `next-turn` inbox splice,随后直接开始编号连续的下一轮。 +v0-to-v1 除了有限的 released-v0 归一化外,会保留逻辑 header、seq、引用、时间戳与 payload。它转换已移除的 `steering/message` 与 `compact/*` 事件名称,接受出现在对应 `step/end` 之后的已发布 `llm/retry`,按 turn/step/provider/policy chain 为缺失的 `llm/retry.retryId` 确定性补值,并为省略 id 的旧 compaction group 确定性补充同一个 `compactionId`。v1-to-v2 负责 attempt folding 与引用重写,并且只 emit 已结算的 v2 event。它会把旧的 goal 来源 user message 拆成 `goal/change` 与原本的模型可见 message。它还会为一种有限的已发布 restart 插入 interrupted `turn/end`:一个没有 open step 的 open turn 后出现非空 `next-turn` inbox splice,随后直接开始编号连续的下一轮。 Catalog 为 production、Worker、fixture 与 replay 暴露同一个 `createRestore()`。Recovery policy 与最终 validation policy 在 restore 创建时一次确定。Historical production 使用 recoverable source parsing 与 transformed-current validation;这种策略会在迁移后校验已发布 current 结果,而已经是 current 的输入只接受 codec 校验。Worker 与 fixture verification 使用 strict parsing 与已安装 current 格式的完整 restoration。Migration stage 或 transformed-current validation 的拒绝会保持为 `SessionFormatUnsupportedMigrationError`;物理解码失败仍是 corruption。Test support 只保留 fixture 自身需要的 token 和 envelope materialization。 @@ -77,6 +94,8 @@ JSONL provider 只扫描一次 frame boundary,复用一个 Zstandard decoder Current encode 以单条 record 为单位。Provider 在主线程每个 slice 序列化约 1 MiB plaintext,通过一个会传播 source error 的 Zstandard context 流式压缩,以 4 MiB batch 写入同目录排他创建的临时文件,并在 publication 前 sync。进程级 scheduler 最多允许两个完整 verification Worker 并行,并把释放的 permit 直接交给最早的 waiter。 +发布的 `lib/worker.cjs` 将 JavaScript workspace 依赖一起打包,使每个新 verifier 无需解析并编译它们的运行时模块图。Worker 只通过普通 request/result 消息通信,与 host 不共享 service 或 class identity,因此可以这样处理。Host build 应用现有 TypeScript 与 Typert 转换;Client pass 跳过这个 Node-only package,不会用未经转换的源代码覆盖 worker。Native add-on 保持 external。Verification、scheduler admission、termination 与 durable publication 仍在 writable open 返回前完成。Built-worker 冒烟测试把 package manifest 与 worker 复制到隔离的临时 package,移除环境中的模块搜索路径,接受有效 generation,并拒绝错误的 event count。 + Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms Decode yield 边界观察它。`publish()` 一旦开始,encode、Worker verification 与 publication 不接收 caller cancellation,并运行到终态;write open 会在之后再次检查 caller signal。已经发布的 generation 绝不会回滚。 Stage pipeline 终止于一份 prepared current artifact。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。 @@ -105,6 +124,10 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o ## 验证 +迁移规范要求分别提供转换、保留与拒绝的证据。直接迁移边和原生 V3 测试不能证明有种子的多跳发布:前代 assistant 流折叠会在 V3 插入系统事件前改变源坐标。因此,经过真实目录与 JSONL 提供方的测试需要原始及压缩的 V0/V1 输入、映射后的引用和继承切点、发布/重新打开等价性、前代字节不变,以及不产生中间代。覆盖率百分比本身不能证明这些跨阶段关系;组合断言必须比较结果历史与拒绝效果。 + +内容准入证据必须覆盖规范列出的每个位置、嵌套结果、未完成的起始记录和已知种类的畸形块,并验证诊断使用源坐标。成功迁移必须保留已接纳的内容与不透明值。经真实持久化路径拒绝时,必须保持源不变且不发布后继代。原生 V3 测试必须独立证明两种目录校验策略均保留扩展准入;历史拒绝不能证明原生输入也被拒绝。 + ### Benchmark 输入与口径 Benchmark 使用 Node v24.18.0 和一份 116,228,655-byte 的 v0 Zstandard 日志,其中包含 317,540 个 frame 与 454,151 个 physical row。老 reader 会恢复 9,143,111 个展开后的 v0 event;migration 会生成 72,784 个 current v2 event,artifact SHA-256 为 `fa16ff9472ca350595a3112c20a3db79655bc2673973469987ecaf2a57ebd17c`。 diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml new file mode 100644 index 0000000000..697c410f3a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-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-09-01-parent-owned-subagent-catalog.md +2026-09-01-parent-owned-subagent-catalog.md: 5926af77219680a48af1e5fb062ddc11a91a2280 +2026-09-01-parent-owned-subagent-catalog.zh.md: ddc77a665207169cfc048b9f2cfbd4f912cb93dc diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md new file mode 100644 index 0000000000..5926af7721 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md @@ -0,0 +1,49 @@ +# Agent Note: Parent-owned subagent catalog events + +Status: implemented + +English | [中文](2026-09-01-parent-owned-subagent-catalog.zh.md) + +## Problem + +Direct-child discovery once reconstructed a catalog from the global Session corpus and each selected child's log. Creation already knows the direct parent, child id, mode, and label, so repository-wide enumeration and child-log reads duplicated an owned fact and made browser refresh cost depend on unrelated Sessions. + +The child descriptor remains necessary for recovery and composition, but it cannot be the discovery source because a reader must find and open the child before it can read the descriptor. Forks add a separate requirement: a seeded copy of a parent log must not inherit the original Session's children. + +## Decision + +The parent Session's required `subagent/catalog` events are the persistent authority for direct-child discovery. Each event is one successful creation fact containing `childId`, `childCreatedAt`, mode, and the mode-discriminated label. Remote one-shot runs without a local Session remain outside this catalog. Invalid own facts, including unsupported payload versions, reject projection restoration because silently dropping a required fact would return an incomplete catalog. + +Creation publishes only successful facts. A one-shot run appends the catalog event after its provider returns a local child and before the run reaches its caller. A continuable run admits the initial prompt, appends the catalog event, then returns the child id. If admission or catalog append fails, creation fails and releases the activation; there is no compensating catalog event or rollback protocol. + +The child header and `subagent/descriptor` remain authoritative for recovery and composition. An Activation and the exact parent relationship remain authoritative for authorization and delivery. Mode and label are snapshotted once and the same detached values reach the parent catalog fact and child descriptor. + +The registered `subagentCatalog` projection materializes the parent facts. It delegates storage, append, iteration, and checkpoint validation to [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.md), which stores facts in a persistent stack of 64-entry chunks, so an append copies at most the head chunk in bounded O(1) work. Materialization visits chunks from oldest to newest and preserves parent catalog event order in O(D) time for D facts. Concurrent creation is ordered by successful catalog append, independent of child timestamps and ids. A projection checkpoint clones the state once in O(D); projection-cache writes remain asynchronous and use the existing mandatory creation, turn-end, and disposal points. + +The utility owns chunk layout and its shared capacity constant; the catalog owns event validation, fork filtering, and row conversion. Catalog projection state version 2 stores generic chunk values, so the projection registry rebuilds incompatible caches from Session events. Session event payloads and public catalog rows retain their formats. + +Fork isolation uses the exact `Session.inheritedEventCount` supplied to projection initialization. The fold ignores `subagent/catalog` events below that offset. The state stores the inherited offset but not each event seq because acceptance is decided during folding. + +Headless snapshot collection assigns sibling fixture roles by their parent catalog order, regardless of child creation timestamps: provider startup can publish an older Session after a newer one. The collection preserves each log verbatim. + +Snapshot normalizers zero `childCreatedAt` because it originates from the process clock. Event order and source-event references remain intact: adjacent facts can come from sequential creation, so adjacency does not establish commutativity. + +Current-writer snapshot expectations include catalog facts even when replay input retains a historical Session generation. The comparison preserves the catalog and its source-event references; historical replay files remain unchanged. + +## Alternatives considered + +**A flat immutable array.** Appending with `[...facts, fact]` copies D facts, so creation is O(D). Mutating a shared array would violate projection state ownership and checkpoint safety. + +**A node-per-fact linked list.** It provides O(1) append and O(D) read, but persisted projection checkpoints form JSON nested D levels deep. Sixty-four-entry chunks preserve the asymptotic costs while reducing nesting. + +**Separate host-state observation output.** Returning internal projection states duplicates the existing observation result mechanism and copies states unrelated to child discovery. A catalog view supplies the direct-child list through the existing typed projection map. + +**A durable SQLite child index.** An index would create another write path, reconciliation protocol, schema, and corruption surface for a fact already ordered in the parent Session log. + +**A compensating failure event.** Recording catalog membership before initial prompt admission requires a second operation, pairing rules, rollback cleanup, and client reconciliation. Delaying the success fact until admission completes removes that protocol. + +## Consequences + +Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection. + +Backends that do not know the required event refuse the log under the existing Session event mechanism. Pre-release format policy requires no fallback scan for old logs. diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md new file mode 100644 index 0000000000..ddc77a6652 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md @@ -0,0 +1,49 @@ +# Agent Note: parent 自有的 subagent 目录事件 + +Status: implemented + +[English](2026-09-01-parent-owned-subagent-catalog.md) | 中文 + +## 问题 + +直接 child discovery 曾从全局 Session 语料与每个入选 child 的日志重建目录。创建过程已经知道直接 parent、child id、mode 与 label,因此仓库范围枚举和 child 日志读取重复推导了已有归属的事实,并让浏览器刷新成本取决于无关 Session。 + +child descriptor 对恢复与 composition 仍然必要,但它不能作为 discovery 来源,因为读取方必须先找到并打开 child 才能读取 descriptor。fork 还有独立要求:从 parent 日志播种的副本不能继承原 Session 的 child。 + +## 决策 + +parent Session 的 required `subagent/catalog` 事件是直接 child discovery 的持久化权威。每个事件都是一条成功创建事实,包含 `childId`、`childCreatedAt`、mode 与按 mode 区分的 label。没有本地 Session 的远程 one-shot run 不进入该目录。无效的自身 fact(包括不支持的 payload 版本)会使 projection 恢复失败,因为静默丢弃 required fact 会返回不完整的目录。 + +创建只发布成功事实。one-shot run 在 provider 返回本地 child 后、run 到达调用方前追加目录事件。continuable run 先准入初始 prompt,再追加目录事件,最后返回 child id。准入或目录追加失败时,创建失败并释放 activation;不存在补偿目录事件或 rollback 协议。 + +child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威。Activation 与精确 parent 关系继续拥有授权与投递权威。mode 与 label 只快照一次,同一份分离值写入 parent catalog fact 与 child descriptor。 + +注册的 `subagentCatalog` projection 物化 parent fact。它将存储、追加、迭代和检查点校验交给 [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.zh.md),后者以每块 64 项的持久 stack 保存事实,因此 append 最多复制 head chunk,以有界 O(1) 工作完成。materialization 从旧到新访问 chunk,对 D 条事实以 O(D) 时间保留父目录事件顺序。并发创建按目录成功追加的顺序排列,与 child 时间戳和 id 无关。projection checkpoint 以 O(D) 克隆 state;projection-cache 继续异步写入,并使用既有创建、turn-end 与 disposal 强制点。 + +工具库拥有分块布局及其共享容量常量;目录拥有事件校验、fork 过滤和目录行转换。目录 projection state 版本 2 保存通用块值,因此 projection registry 从 Session 事件重建不兼容的缓存。Session 事件载荷和公开目录行保持各自格式。 + +fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。 + +Headless 快照采集按父目录顺序分配同父子级的 fixture 角色,不依赖子级创建时间戳:provider 启动可能在较新的 Session 之后发布较旧的 Session。采集过程原样保留每份日志。 + +snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clock。事件顺序与来源事件引用保持不变:相邻 fact 也可能来自顺序创建,因此相邻关系不能证明可交换性。 + +即使 replay 输入保留历史 Session generation,当前 writer 的快照预期也包含 catalog 事实。比较保留 catalog 及其来源事件引用;历史 replay 文件保持不变。 + +## 考虑过的替代方案 + +**扁平不可变数组。** 用 `[...facts, fact]` append 会复制 D 个 fact,因此创建是 O(D)。修改共享数组会违反 projection state ownership 与 checkpoint 安全。 + +**每 fact 一个 node 的 linked list。** 它提供 O(1) append 与 O(D) read,但持久 projection checkpoint 会形成 D 层 JSON 嵌套。每块 64 项保留渐进复杂度,同时降低嵌套深度。 + +**独立的 host state 观察输出。** 返回内部 projection state 会重复已有观察结果机制,并复制与子级发现无关的状态。目录视图通过既有的类型化 projection map 提供直接子级列表。 + +**持久 SQLite child index。** index 会为 parent Session 日志中已有顺序的 fact 增加另一套写路径、reconciliation protocol、schema 与 corruption surface。 + +**补偿失败事件。** 在初始 prompt 准入前记录 catalog membership 会引入第二种 operation、配对规则、rollback 清理与 client reconciliation。把成功事实推迟到准入完成后即可删除该协议。 + +## 后果 + +Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。 + +不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。pre-release format policy 不要求为旧日志保留 fallback scan。 diff --git a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml index 7496ece38c..9fbfab3698 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md -2026-09-01-v2-embedded-assistant-streams.md: bee4d50fb830caa277bb700f3415e6d7f98ff64b -2026-09-01-v2-embedded-assistant-streams.zh.md: 9116e94111b78af68d33f6f01dc289ee9f349b7e +2026-09-01-v2-embedded-assistant-streams.md: 98207749028e2182c5e60073fc07985688ecfa30 +2026-09-01-v2-embedded-assistant-streams.zh.md: 90dff5a385cf83071ec52a2fc2b57a893107b861 diff --git a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md index bee4d50fb8..9820774902 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md +++ b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md @@ -14,6 +14,8 @@ Changing event cardinality also changes Session sequence numbers. A released mig ## Decision +The [V3 canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns current replacement-key and header-acceptance rules. It preserves the embedded streams, attempt settlements, and frozen v1-to-v2 conversion described here. + Session format v2 has no top-level `assistant/chunk` event. Each model attempt commits one durable settlement containing `stream: AssistantStreamRecord[]`: - `assistant/message` is the surface settlement for a successful response or a cancelled response with visible assembled content. It embeds the exact compact timed stream beside the assembled message, optional usage, and optional `interrupted: true` marker. diff --git a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md index 9116e94111..90dff5a385 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md @@ -14,6 +14,8 @@ Token 粒度的 `assistant/chunk` 事件会保留精确的 stream 顺序、时 ## 决策 +[V3 规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责当前替换键与请求头接纳规则。它保留本文的嵌入式 stream、尝试结算与冻结的 v1-to-v2 转换。 + Session format v2 没有顶层 `assistant/chunk` 事件。每个模型 attempt 提交一个包含 `stream: AssistantStreamRecord[]` 的持久 settlement: - `assistant/message` 是成功响应或具有可见组装内容的已取消响应所对应的 surface settlement。它在组装 message 旁嵌入精确的紧凑带时间 stream、可选 usage 与可选 `interrupted: true` marker。 diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml new file mode 100644 index 0000000000..7b442c555f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md +2026-09-02-system-prompt-as-surface-node.md: dc0d22b2fb927ad288415346bea9d0c2793cf000 +2026-09-02-system-prompt-as-surface-node.zh.md: 368684d85cb7ddf5d0be63bce905ce48e86cb49f diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md new file mode 100644 index 0000000000..dc0d22b2fb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md @@ -0,0 +1,95 @@ +# Agent Note: The system prompt is surface node 0 + +Status: implemented + +English | [中文](2026-09-02-system-prompt-as-surface-node.zh.md) + +## Problem + +A system prompt held outside the surface has a different durable representation from every other message the model reads. Conversation messages are surface events (`user/message`, `assistant/message`, `tool/result`) folded in seq order by `Session.deriveMessages()`; a prompt stored as a `system` field of the log-only `request/header` snapshot has to be prepended by each serializer as wire message 0. The [reconstructable-requests Agent Note](2026-07-05-reconstructable-requests.md) made both halves durable, but that layout leaves one model-visible fact with two homes: the surface owns the messages, the header owns the message in front of them. + +That split forces every reader of "what did the model see" to join two sources: the compaction summarizer copies the header prompt in front of the region's derived messages, `dsh-token-meter` estimates the system prompt from the header while pricing every other message from the surface, and the Web request-prompt card, the trajectory view, and the snapshot normalizer's `{{system}}` placeholder each read the header on their own. Change detection is split the same way: a `headerEquals` that compares `system` byte-for-byte beside `config` and `tools` makes a prompt change and a tool change indistinguishable in the log (`request/header` reason `change`) even though they are different operations on the conversation. + +The split also blocks the next step. A model that accepts a mid-conversation `system` message as a prompt replacement needs the harness to append a system-role message to history; with the prompt living in the header there is no surface representation to append, and the header would have to be frozen by special case. The [in-history replacement decision](../feature/2026-09-02-in-history-system-prompt-replacement.md) depends on this note. + +## Decision + +The system prompt lives on the surface. It is an ordinary surface event, `system/message`, and every prompt lifecycle operation is one of the two existing `SurfaceOp` variants applied to that event type. The wire request is unchanged: the surface fold yields the message list the serializers send, with the system message first. + +### The event + +`system/message` is a member of `SurfaceEventType` beside `user/message`, `assistant/message`, and `tool/result` (`packages/core/session/src/types.ts`). Its payload mirrors `tool/result`: `{ turn, step, message }`, where `message` is a `SystemMessage` with `role: 'system'`, one text block holding the rendered prompt, and source `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`. Empty `content` records "no system prompt": the node keeps its surface position and `deriveEventMessage` projects it to `null`, so it contributes no wire message. A non-empty node projects verbatim, so `deriveMessages()` returns the system message at its surface position and the DeepSeek serializers, which pass a `role: 'system'` history message through unchanged, emit it as wire message 0. `EpochHeader` is `{ config, adapterDefaults?, tools? }`; `canonicalHeader` and `headerEquals` in `packages/core/session/src/request-header.ts` compare config, adapter defaults, and tools only. + +### The operations + +| Situation | Surface operation | +|---|---| +| No `system/message` survives on the surface (including an empty rendered prompt) | append `system/message`; on the session's first step it is surface node 0, before the first `user/message` of the step | +| A `system/message` survives and the rendered prompt differs from its text (including a prompt that becomes empty) | replace exactly that node: `surfaceOp: { op: 'replace', startSeq: , endSeq: }`, `sourceEventSeqs: []`; an empty prompt produces an empty-content node that projects to no message | +| The rendered prompt equals the surviving node's text | no operation | + +When the initial rendered prompt is empty, the loop reserves an empty system head before the initial admitted user messages so a prompt that first becomes non-empty later still replaces node 0. Omitting that empty node would append the later prompt behind user history, where pi-ai converts it to a user message rather than its `systemPrompt`. Replacing node 0 is a head rewrite expressed on the surface: the provider prefix changes from the first token, the log records the shadowed node through `sourceEventSeqs`, and `replaceGeneration` advances as it does for a compaction replacement. The loop's `startsSeries` detection (`requestSurfaceGeneration !== surfaceGeneration`) therefore covers the prompt change without a `system` comparison in `headerEquals`. `request/header` keeps reasons `initial`, `resume`, `change`, and `series`; `change` means config or tools changed, and the unchanged header that follows a prompt replacement logs as `series`. + +`packages/core/session/src/surface.ts` enforces the head invariant in `assertSystemHeadRewrite`: a replacement whose range covers surface node 0 while node 0 is a `system/message` is rejected unless the replacing event is itself a `system/message` covering exactly that node. System nodes at later positions carry no such protection; a compaction range may shadow them. + +### Ownership in the loop + +`dsh-agent-loop` owns `SystemPromptProjection` beside `RuntimeContextProjection` in `packages/core/agent-loop/src/runtime-context.ts`. It reads the surviving `system/message` nodes from the current surface on every projection, so a compaction or replacement that ran earlier in the same step is already reflected. `project(rendered, { inHistory, startsSeries })` returns `{ message, intent }` — `intent` is `{ surfaceOp: 'append' }` when no system node survives or when the [in-history rule](../feature/2026-09-02-in-history-system-prompt-replacement.md) applies, otherwise a replacement of exactly the latest surviving system node — or `undefined` when the latest node already holds the rendered text. + +In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `renderPrompt(assembly)` and projects it after the `agent/pre-step` waterfall, so a compaction provider's replacement inside that waterfall is visible to the decision; `turn()` commits the `system/message` immediately after `step/start` and before the step's `user/message` events, so log order is wire order. `buildRequest` sets no `system` on the request: the request is `header.config`, `session.deriveMessages()` (system message first), and `header.tools`. The loop step order is: claim inbox → `systemPrompt.assemble()` → project runtime context → `agent/pre-step` waterfall → project system prompt → `step/start` → commit `system/message` (when changed) → commit `user/message`s → `agent/request` waterfall → `request/header` → `request/context` → stream. The `dsh-agent-loop/invariant` companion (`packages/core/agent-loop/src/invariant.ts`) asserts that a loop-built request has `system === undefined` and `messages` equal to `deriveMessages()`. + +`dsh-token-meter` anchors usage to the priced surface immediately before the successful `assistant/message`, not to `step/start`. The loop admits the system prompt and user messages after step start, and retry recovery can replace nodes before rebuilding the request. Capturing that current surface includes every admitted input once; the embedded provider output remains separately priced so durable assistant rewrites retain their signed delta. The open step stores only turn and step for lifecycle validation, not a second node snapshot. + +### Consumers + +| Consumer | Reads | +|---|---| +| DeepSeek serializers (`serializeRequest`, `serializeRequestWithImages`) | `options.messages`, passing the `role: 'system'` history message through as wire message 0; `GenerateOptions.system` remains for direct one-shot callers such as title providers | +| `dsh-llm-pi-ai` | a leading system history message maps to pi-ai's `systemPrompt` | +| `compaction-basic` `buildSummarizationInput` | node 0's derived message prepended to the region in `SummarizationInput.messages`, with no separate `system` field; an empty-content head projects to no message while staying protected from compaction | +| `compaction-basic` `selectCompactableRange` | anchors at the first non-system node; node 0 is never inside a compaction range | +| `dsh-token-meter` | the system node is priced as a surface node under the `systemTokens` breakdown | +| Web request-prompt card, trajectory request node, request inspection | the `system/message` node; a replaced node 0 is shown as a prompt change and an appended in-history node as a prompt update, each in a collapsed inspectable card, never a chat bubble | +| Snapshot normalizer `{{system}}` placeholder, plan-mode tests | the system node's text | +| TypeScript and Python SDK expected outputs | include the `system/message` event | +| Human transcript projections | skip `system/message`; it is model history, not conversation | + +`RuntimeContextProjection` and `SystemPromptProjection` both hand the loop an uncommitted message that `turn()` commits. They differ in how they observe the surface and in their operation set: runtime context follows `session/event` for its owned user-role snapshots and appends only, while the system prompt scans the current surface for system nodes on each projection because its decision depends on how many survive, and it appends or replaces per the route. + +### V2-to-V3 structural conversion + +The [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#system-head) owns system-head conversion and message identities; its [reference rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) and [source refusal](../../../../packages/session/session-format-v2-to-v3/README.md#source-audit) define preservation and unsupported inputs. The migrated layout is semantically equivalent to native requests, not byte-identical to a native recording. A valid V2 source can lack an order-preserving conversion under the current step invariant; refusing it is preferable to moving history or relaxing ownership. Historical acceptance coordinates must not become acknowledgements of the transformed log. + +The [released-format policy](2026-08-31-released-session-format-migrations.md) keeps V0, V1, and V2 generations byte-frozen and publishes only V3 successors. V3 is one unreleased target, not a new version per feature; it can evolve before release, so integration requires disposable homes. An existing V3 generation does not rerun V2-to-V3. Projection-cache version 4 is independent of the Session format and does not imply Session V4. + +The [canonical-envelope specification](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes) defines composition with the structural conversion; the [canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns the strict-acceptance rationale. + +## Alternatives considered + +**Keep `header.system` and add `system/message` only for updates.** Two homes for one fact: every consumer above would read the header for message 0 and the surface for later messages, and the loop would need a special case that ignores `system` in `headerEquals` while a surface system node exists. Rejected because the point of the change is one representation. + +**A dedicated log-only `system-prompt/change` event that rewrites the header.** Preserves the header as the home of the prompt and records changes as their own event kind, but still cannot express a system message inside history, so the in-history proposal would need a second mechanism anyway. Rejected. + +**Synthesize the system message inside the adapter from consecutive headers.** The adapter is stateless per request and never sees the log; a wire history that depends on adapter state is not reconstructable from the surface fold. Rejected. + +**Express the prompt as a `user/message` snapshot like runtime context.** Reuses an existing event type but sends the wrong role, so a model that treats a system message as authoritative would not. Rejected. + +## Consequences + +- One representation: every reader of "what did the model see" folds the surface; no consumer joins the header to the message list. `EpochHeader` has no `system` field, so a reader that expects one fails at compile time. +- A prompt change and a tool or config change are distinguishable in the log: the former is a `system/message` replacement of node 0 followed by a `series` header, the latter a `request/header` with reason `change`. +- Compaction carries an invariant: node 0 is never compacted. The `dsh-session` surface manager enforces it in the replace operation itself, so a compaction provider other than `compaction-basic` cannot shadow the prompt by anchoring at `surfaceNodes[0]`. Later system nodes are unprotected by design. +- `replaceGeneration` advances for a prompt replacement as well as for compaction; a reader that needs to distinguish them inspects the replacement event's type. +- A mid-history system node has a surface representation, which is what the [in-history replacement decision](../feature/2026-09-02-in-history-system-prompt-replacement.md) builds on. +- An initially empty prompt occupies the protected head without contributing a wire message; in replacement mode, a later non-empty prompt replaces it and remains the leading system message. +- Recorded snapshot fixtures carry the `system/message` event instead of a header `system` field. The snapshot normalizer tokenizes that event's text to `{{system}}`, the prompt sidecar is harvested from the `system/message` sequence (one section per prompt version, declared as `header.promptChanges`), and `request/header` pins compare config and tools only. + +## Testing + +- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` pins zero post-call surface delta with provider usage through initial, growing, shrinking, and empty prompts, same-step retry replacement, request middleware, and fresh replay. +- `packages/core/session/tests/surface.spec.ts` (`system/message surface node` block) pins the leading system-role projection, the empty-content `null` projection, `assertSystemHeadRewrite`'s acceptance and rejection paths, the unprotected later system nodes, and the rejection of a seeded `system/message` with a non-system role or non-plugin source. +- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on first render (including empty), the later non-empty prompt at the derived head in replacement mode, the no-op on an unchanged prompt, the replacement of the latest surviving node on change, the tail append after a replacement shadowed a non-head system node, and the in-history append and re-baseline rules. +- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` (`a system-prompt change replaces surface node 0 and starts a new series under the same header`) pins the `series` header that follows a prompt replacement. +- `packages/core/agent-loop/tests/invariant.spec.ts` pins the companion's rejection of a loop request carrying a `system` field and its `messages` equality check against the boundary derivation. +- `packages/llm/llm-deepseek/tests/serialize.spec.ts` (`serializes a leading system message byte-for-byte like the same prompt passed as options.system`) pins wire identity. `packages/llm/llm-pi-ai/tests/context.spec.ts` compares both system sources on text and image paths. `packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` pins the derived prefix, routed tools, absent separate `system` option, and protected non-empty or empty head through the region transaction and default summarizer. +- The recorded snapshots under `snapshots/` pin the model-visible wire request of every shipped profile; a recorded session that renders a prompt carries the `system/message` event at surface node 0 in its `session.jsonl`, and a session with a mid-session prompt change carries the replacement of node 0 or, on an in-history route, the appended node. diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md new file mode 100644 index 0000000000..368684d85c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md @@ -0,0 +1,95 @@ +# Agent Note: 系统提示词是 surface 的第 0 号节点 + +Status: implemented + +[English](2026-09-02-system-prompt-as-surface-node.md) | 中文 + +## Problem + +放在 surface 之外的系统提示词,其持久化表示与模型读到的其他所有消息都不同。对话消息是 surface 事件(`user/message`、`assistant/message`、`tool/result`),由 `Session.deriveMessages()` 按 seq 顺序折叠;而存放在仅记日志的 `request/header` 快照 `system` 字段中的提示词,必须由每个序列化器前置为协议消息 0。[可重建请求 Agent Note](2026-07-05-reconstructable-requests.zh.md) 让两半都成为持久数据,但这种布局让一个模型可见的事实拥有两个归属:surface 拥有消息,header 拥有排在这些消息之前的那条消息。 + +这种拆分迫使每个想知道「模型看到了什么」的读取方都要合并两个来源:压缩(compaction)摘要器把 header 中的提示词复制到区域派生消息之前,`dsh-token-meter` 从 header 估算系统提示词却从 surface 为其他每条消息计价,Web 请求提示词卡片、轨迹视图和快照归一化器的 `{{system}}` 占位符各自单独读取 header。变更检测同样被拆开:在 `config` 和 `tools` 旁边逐字节比较 `system` 的 `headerEquals`,让提示词变更与工具变更在日志中无法区分(`request/header` 的 reason 都是 `change`),尽管它们是对对话的两种不同操作。 + +这种拆分还阻塞了下一步。一个把对话中途的 `system` 消息当作提示词替换来接受的模型,需要 harness 向历史追加一条 system 角色消息;当提示词住在 header 里时,没有可追加的 surface 表示,header 也只能靠特例被冻结。[历史内替换决定](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 依赖本 Agent Note。 + +## Decision + +系统提示词住在 surface 上。它是一个普通的 surface 事件 `system/message`,提示词生命周期中的每个操作都是对该事件类型施加现有两种 `SurfaceOp` 变体之一。协议请求不变:surface 折叠产出的就是序列化器发送的消息列表,系统消息在最前面。 + +### 事件 + +`system/message` 是 `SurfaceEventType` 的成员,与 `user/message`、`assistant/message`、`tool/result` 并列(`packages/core/session/src/types.ts`)。它的载荷与 `tool/result` 对称:`{ turn, step, message }`,其中 `message` 是 `role: 'system'` 的 `SystemMessage`,一个文本块承载渲染后的提示词,source 为 `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`。空的 `content` 记录「没有系统提示词」:该节点保持其 surface 位置,`deriveEventMessage` 把它投影为 `null`,因此不贡献任何协议消息。非空节点逐字投影,因此 `deriveMessages()` 在其 surface 位置返回系统消息,而原样透传 `role: 'system'` 历史消息的 DeepSeek 序列化器把它作为协议消息 0 发出。`EpochHeader` 是 `{ config, adapterDefaults?, tools? }`;`packages/core/session/src/request-header.ts` 中的 `canonicalHeader` 与 `headerEquals` 只比较 config、适配器默认值和工具。 + +### 操作 + +| 情形 | surface 操作 | +|---|---| +| surface 上没有存活的 `system/message`(包括渲染后的提示词为空时) | 追加 `system/message`;在会话的首个步骤中它是 surface 第 0 号节点,位于该步骤首条 `user/message` 之前 | +| 有存活的 `system/message` 且渲染后的提示词与其文本不同(包括提示词变为空) | 恰好替换该节点:`surfaceOp: { op: 'replace', startSeq: <该节点的 seq>, endSeq: <同一值> }`,`sourceEventSeqs: [<该节点的 seq>]`;空提示词产生一个投影为无消息的空内容节点 | +| 渲染后的提示词与存活节点的文本相同 | 无操作 | + +当初始渲染的提示词为空时,循环在初始接纳的用户消息之前预留空系统头部,使稍后首次变为非空的提示词仍替换第 0 号节点。省略该空节点会让后来的提示词追加在用户历史之后,pi-ai 会将其转换为用户消息,而不是 `systemPrompt`。替换第 0 号节点是头部重写在 surface 上的表达:提供方前缀从第一个 token 起改变,日志通过 `sourceEventSeqs` 记录被遮蔽的节点,`replaceGeneration` 与压缩替换时一样推进。因此循环的 `startsSeries` 检测(`requestSurfaceGeneration !== surfaceGeneration`)无需在 `headerEquals` 中比较 `system` 即可覆盖提示词变更。`request/header` 保留 `initial`、`resume`、`change`、`series` 四种 reason;`change` 表示 config 或 tools 变更,提示词替换之后跟随的未变 header 记为 `series`。 + +`packages/core/session/src/surface.ts` 在 `assertSystemHeadRewrite` 中强制头部不变量:当第 0 号节点是 `system/message` 时,范围覆盖第 0 号节点的替换会被拒绝,除非替换事件本身是恰好覆盖该节点的 `system/message`。位于更后位置的系统节点没有此类保护;压缩范围可以遮蔽它们。 + +### 循环中的归属 + +`dsh-agent-loop` 在 `packages/core/agent-loop/src/runtime-context.ts` 中与 `RuntimeContextProjection` 并列拥有 `SystemPromptProjection`。它在每次投影时从当前 surface 读取存活的 `system/message` 节点,因此同一步骤中更早运行的压缩或替换已经反映在内。`project(rendered, { inHistory, startsSeries })` 返回 `{ message, intent }`——没有系统节点存活或[历史内规则](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md)适用时 `intent` 为 `{ surfaceOp: 'append' }`,否则是对最新存活系统节点的精确替换——最新节点已持有渲染文本时返回 `undefined`。 + +在 `packages/core/agent-loop/src/agent.ts` 中,`preStep` 用 `renderPrompt(assembly)` 渲染提示词,并在 `agent/pre-step` waterfall 之后投影它,因此压缩提供者在该 waterfall 内做出的替换对决定可见;`turn()` 紧接在 `step/start` 之后、该步骤的 `user/message` 事件之前提交 `system/message`,因此日志顺序即协议顺序。`buildRequest` 不在请求上设置 `system`:请求由 `header.config`、`session.deriveMessages()`(系统消息在先)和 `header.tools` 构成。循环步骤顺序为:领取收件箱 → `systemPrompt.assemble()` → 投影运行时上下文 → `agent/pre-step` waterfall → 投影系统提示词 → `step/start` → 提交 `system/message`(有变化时) → 提交各条 `user/message` → `agent/request` waterfall → `request/header` → `request/context` → 流式请求。`dsh-agent-loop/invariant` 伴随组件(`packages/core/agent-loop/src/invariant.ts`)断言循环构建的请求满足 `system === undefined` 且 `messages` 等于 `deriveMessages()`。 + +`dsh-token-meter` 把用量锚定到成功的 `assistant/message` 之前的已计价 surface,而不是 `step/start`。循环在步骤开始之后接纳系统提示词与用户消息,重试恢复还可能在重建请求之前替换节点。捕获当前 surface 会让每个已接纳输入恰好计入一次;内嵌的提供方输出仍单独计价,因此持久 assistant 改写保留其带符号增量。开放步骤只保存 turn 与 step 以验证生命周期,不保存第二份节点快照。 + +### 消费方 + +| 消费方 | 读取内容 | +|---|---| +| DeepSeek 序列化器(`serializeRequest`、`serializeRequestWithImages`) | `options.messages`,把 `role: 'system'` 的历史消息作为协议消息 0 透传;`GenerateOptions.system` 为标题提供方等直接单次调用方保留 | +| `dsh-llm-pi-ai` | 开头的 system 历史消息映射为 pi-ai 的 `systemPrompt` | +| `compaction-basic` 的 `buildSummarizationInput` | 第 0 号节点的派生消息前置于 `SummarizationInput.messages` 中的区域消息,无单独的 `system` 字段;空内容头节点不投影为消息,但仍受保护而不能被压缩 | +| `compaction-basic` 的 `selectCompactableRange` | 锚定在首个非系统节点;第 0 号节点永不落入压缩范围 | +| `dsh-token-meter` | 系统节点作为 surface 节点计价,归入 `systemTokens` 明细 | +| Web 请求提示词卡片、轨迹请求节点、请求检视 | `system/message` 节点;被替换的第 0 号节点显示为提示词变更,追加的历史内节点显示为提示词更新,各自以折叠可检视的卡片呈现,永不作为聊天气泡 | +| 快照归一化器的 `{{system}}` 占位符、plan-mode 测试 | 系统节点的文本 | +| TypeScript 与 Python SDK 预期输出 | 包含 `system/message` 事件 | +| 人类 transcript(文本记录)投影 | 跳过 `system/message`;它是模型历史,不是对话 | + +`RuntimeContextProjection` 与 `SystemPromptProjection` 都把一条未提交的消息交给循环由 `turn()` 提交。两者在观察 surface 的方式与操作集上不同:运行时上下文跟随 `session/event` 观察自己拥有的 user 角色快照且只做追加,而系统提示词在每次投影时扫描当前 surface 上的系统节点,因为它的决定取决于有多少节点存活,并按路由追加或替换。 + +### V2-to-V3 结构转换 + +[V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#system-head)负责系统头节点转换与消息身份;其[引用规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)和[源拒绝](../../../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)定义保留内容与不支持的输入。迁移布局与原生请求语义等价,而非与原生录制逐字节相同。有效 V2 源在当前步骤不变量下可能没有保持顺序的转换方式;拒绝它优于移动历史或放宽归属。历史接收坐标不得变为对转换后日志的确认。 + +[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)保持 V0、V1、V2 代际字节冻结,并且只发布 V3 后继代际。V3 是一个尚未发布的目标,而不是每个功能一个新版本;它在发布前可以演化,因此集成必须使用可丢弃的 home。已有 V3 代际不会重跑 V2-to-V3。投影缓存版本 4 独立于 Session 格式,并不意味着 Session V4。 + +[规范信封规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)定义与结构转换的组合;[规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责严格准入的依据。 + +## Alternatives considered + +**保留 `header.system`,只为更新添加 `system/message`。** 一个事实两个归属:上述每个消费方都要从 header 读消息 0、从 surface 读后续消息,循环还需要一个在 surface 存在系统节点时让 `headerEquals` 忽略 `system` 的特例。被否决,因为本次变更的目的就是单一表示。 + +**用专门的仅记日志事件 `system-prompt/change` 重写 header。** 保留 header 作为提示词归属,并把变更记录为独立事件种类,但仍无法表达历史内部的系统消息,历史内替换提案还是需要第二套机制。被否决。 + +**在适配器内根据相邻 header 合成系统消息。** 适配器逐请求无状态且从不接触日志;依赖适配器状态的协议历史无法从 surface 折叠重建。被否决。 + +**像运行时上下文那样用 `user/message` 快照表达提示词。** 复用了现有事件类型,却发送了错误的角色,因此把系统消息视为权威的模型不会这样对待它。被否决。 + +## Consequences + +- 单一表示:每个想知道「模型看到了什么」的读取方都折叠 surface;没有消费方需要把 header 与消息列表合并。`EpochHeader` 没有 `system` 字段,因此期望该字段的读取方在编译期失败。 +- 提示词变更与工具或 config 变更在日志中可以区分:前者是对第 0 号节点的 `system/message` 替换加随后的 `series` header,后者是 reason 为 `change` 的 `request/header`。 +- 压缩带有一条不变量:第 0 号节点永不被压缩。`dsh-session` 的 surface 管理器在替换操作本身中强制它,因此除 `compaction-basic` 以外的压缩提供方无法通过锚定在 `surfaceNodes[0]` 来遮蔽提示词。更后位置的系统节点按设计不受保护。 +- `replaceGeneration` 在提示词替换时和压缩时一样推进;需要区分两者的读取方检查替换事件的类型。 +- 历史中途的系统节点拥有 surface 表示,这正是[历史内替换决定](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md)所依赖的基础。 +- 初始空提示词占据受保护的头部,但不贡献协议消息;在替换模式下,后来的非空提示词替换它,并保持为开头的系统消息。 +- 录制的快照 fixture 携带 `system/message` 事件而非 header 的 `system` 字段。快照归一化器把该事件的文本标记化为 `{{system}}`,提示词伴随文件从 `system/message` 序列采集(每个提示词版本一节,以 `header.promptChanges` 声明),`request/header` 的 pin 只比较 config 与 tools。 + +## Testing + +- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` 钉住提供方用量下调用后的表面增量为零,覆盖初始、增长、缩短与空提示词、同一步骤中的重试替换、请求中间件和全新回放。 +- `packages/core/session/tests/surface.spec.ts`(`system/message surface node` 块)钉住开头 system 角色的投影、空内容的 `null` 投影、`assertSystemHeadRewrite` 的接受与拒绝路径、更后位置系统节点不受保护,以及对 seed 中非 system 角色或非插件 source 的 `system/message` 的拒绝。 +- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住首次渲染时的追加(包括空提示词)、替换模式下后来非空提示词位于派生历史头部、提示词未变时的无操作、变更时对最新存活节点的替换、替换遮蔽了非头部系统节点之后的尾部追加,以及历史内追加与重新基线规则。 +- `packages/core/agent-loop/tests/request-reconstruction.spec.ts`(`a system-prompt change replaces surface node 0 and starts a new series under the same header`)钉住提示词替换之后跟随的 `series` header。 +- `packages/core/agent-loop/tests/invariant.spec.ts` 钉住伴随组件对携带 `system` 字段的循环请求的拒绝,以及其 `messages` 与边界派生结果的相等性检查。 +- `packages/llm/llm-deepseek/tests/serialize.spec.ts`(`serializes a leading system message byte-for-byte like the same prompt passed as options.system`)钉住协议一致性。 `packages/llm/llm-pi-ai/tests/context.spec.ts` 在文本与图片路径上比较两种系统提示词来源。`packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` 通过区域事务与默认摘要器钉住派生前缀、已路由工具、不携带单独 `system` 选项,以及非空或空头节点的保护。 +- `snapshots/` 下的录制快照钉住每个随发 profile 的模型可见协议请求;渲染了提示词的录制会话在其 `session.jsonl` 中于 surface 第 0 号节点携带 `system/message` 事件,会话中途发生提示词变更的会话则携带对第 0 号节点的替换,或在历史内路由上携带追加的节点。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml index 72cf5954dc..aba709660e 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-client-resource-model.md -2026-09-05-client-resource-model.md: 75502ffc91af049bf89b7c36ec6ae3dc1339a5f8 -2026-09-05-client-resource-model.zh.md: d1430e88fc16b46a6ad32bbeacb1d59e0a7f6131 +2026-09-05-client-resource-model.md: e6ad8dae331d8a6392585f5dc8eb8fc52150bdc3 +2026-09-05-client-resource-model.zh.md: d716aa8a42f5c3037a329deabd572a56ab1b0537 diff --git a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md index 75502ffc91..e6ad8dae33 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md +++ b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md @@ -8,7 +8,7 @@ English | [中文](2026-09-05-client-resource-model.zh.md) A right-Sidebar tab body, a chat card, or any other slot component often needs live data it knows only by address: the file an agent just wrote, later a chat node or a terminal. Before the resource model each consumer fetched for itself — the text preview owned its own Remote call and refresh loop — so every mount re-read, two components showing one file held two copies, switching tabs unmounted the body and lost its content, and each new kind of content meant a new bespoke hook. -The tab record set the constraint. A tab must survive undo, redo, reload, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data. +The tab record set the constraint. A tab must survive undo, redo, body remount, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. Browser-page reload resets the memory-only Sidebar state. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data. ## Decision @@ -16,7 +16,7 @@ The tab record set the constraint. A tab must survive undo, redo, reload, and ho ### Addresses -A resource address is a `dsh-resource:///…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session//`, with `session/` naming the session whose root resolves the file, or `dsh-resource://file/absolute/`, which carries no session and is read through the current one ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). +A resource address is a `dsh-resource:///…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session//`, with `session/` naming the Session whose Host workspace resolves the relative or absolute path ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). ### The service @@ -30,32 +30,30 @@ interface Resources { interface ResourceProvider

{ readonly protocol: P open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable> - reload?(address: string): void } interface ResourceSnapshot { readonly status: 'none' | 'loading' | 'live' | 'failed' readonly value: Value | undefined readonly failure: RemoteFailure | undefined - readonly reload: () => void } type UseResource =

(address: string) => ResourceSnapshot ``` -`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileResource`); the resources package re-exports the type. +`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileStat`); the resources package re-exports the type. ### The hook -`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource

(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or no `reload`. +`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource

(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. ### Frames -A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ absolutePath, version, bytes?, changed }`, and a consumer reads content itself, by page, through the [Workspace Files service](2026-09-05-workspace-files-service.md). +A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ absolutePath, version, bytes? }`, and a consumer reads content itself through the [Workspace Files service](2026-09-05-workspace-files-service.md). ### Lifecycle -One record exists per address. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render. +One record exists per address. Holds start and stop observation, not the underlying file or Session. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render. The right Sidebar's Tab domain pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream and switching back reads the latest value; a record restored by undo is a new pin, and a resource the model already let go is read again ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). `openResource(address)` accepts resource addresses only; pages such as the guide and the file tree are opened by kind and never enter the resource model. @@ -63,7 +61,7 @@ The right Sidebar's Tab domain pins every open tab record's address for the reco **Session-bound resources: `useResource` on the session kit and a `(session, address)` identity.** The first form. Rejected because a file is not a session concern — the session is only who authorizes the path — and because the model must serve protocols and components outside any session scope. Identity became the address alone, the scope moved into the address grammar, and the hook moved to the global kit. -**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer reads content by page, which is also what lets one open tab hold a multi-megabyte file at the cost of one page. +**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer chooses how much content to read and retain. **Failure as a thrown error, wrapping a non-`RemoteFailure` throw as `gateway/internal`.** Rejected: the Remote face never rejects, so anything a provider throws is a bug, and wrapping it would be a fallback that hides the bug from the developer who caused it. A failure is an `ok: false` frame; a throw surfaces. @@ -71,17 +69,17 @@ The right Sidebar's Tab domain pins every open tab record's address for the reco **A hand-parsed scheme prefix instead of the URL parser.** The first `protocolOf` matched a regular expression for the scheme. Rejected once addresses were URLs: the parser already decides validity and case, and a string it rejects should read as "no protocol" rather than be half-parsed. -**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is owner props on the tab plus one client-wide `useResource` keyed by address. +**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is the framework-bound `useTabInfo` on the tab plus one client-wide `useResource` keyed by address. ## Consequences -Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, reload, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration. +Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, body remount, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration. The costs are recorded here so they are not rediscovered. Records are never reclaimed: memory grows with the number of distinct addresses ever read, not with reads. Abort compliance rests with the provider; the model drops what a released stream still yields but cannot stop a provider that ignores the signal before its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call has to mint one. A navigation address or a malformed string reads as `none` rather than an error, which keeps mixed address lists cheap to render but gives a misspelled protocol no diagnostic beyond the missing value. ## Testing -`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, failure frames beside the last value, and `reload` forwarding. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook. +`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, and failure frames beside the last value. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook. ## Deferred diff --git a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md index d1430e88fc..d716aa8a42 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md @@ -8,7 +8,7 @@ Status: implemented 右侧 Sidebar 的 tab 正文、聊天卡片或任何别的 slot 组件,常常需要只以地址可知的活数据:agent 刚写的文件,将来的聊天节点或终端。资源模型出现前每个消费方各自取数——文本预览自己持有 Remote 调用与刷新循环——于是每次挂载都重读、两个组件显示同一文件就持有两份、切 tab 卸载正文就丢内容,每种新内容都意味着一个新的专用 hook。 -约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、刷新与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。 +约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、正文重挂载与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。浏览器页面刷新会重置只在内存中的 Sidebar 状态。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。 ## Decision @@ -16,7 +16,7 @@ Status: implemented ### 地址 -资源地址是 `dsh-resource:///…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session//<相对该会话工作区根的路径>`,`session/` 命名以其根解析该文件的会话;或 `dsh-resource://file/absolute/<绝对路径>`,不带会话、经当前会话读取([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。 +资源地址是 `dsh-resource:///…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session//`,`session/` 命名由其 Host 工作区解析相对或绝对路径的 Session([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。 ### 服务 @@ -30,32 +30,30 @@ interface Resources { interface ResourceProvider

{ readonly protocol: P open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable> - reload?(address: string): void } interface ResourceSnapshot { readonly status: 'none' | 'loading' | 'live' | 'failed' readonly value: Value | undefined readonly failure: RemoteFailure | undefined - readonly reload: () => void } type UseResource =

(address: string) => ResourceSnapshot ``` -`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileResource`);resources 包再导出这个类型。 +`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileStat`);resources 包再导出这个类型。 ### hook -`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource

(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作。 +`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource

(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。 ### 帧 -提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)按页读内容。 +提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes? }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)读内容。 ### 生命周期 -每个地址一条记录。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。 +每个地址一条记录。持有只控制观察的启停,不控制底层文件或 Session 的生灭。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。 右侧 Sidebar 的 Tab 域在每条打开的 tab 记录存续期内钉住其地址,所以切 tab 卸载正文不关流、切回读到最新值;撤销恢复的记录是一次新的钉住,模型已放掉的资源会重新读取([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。`openResource(address)` 只收资源地址;引导页与文件树这类页面按 kind 打开,从不进入资源模型。 @@ -63,7 +61,7 @@ type UseResource =

(address: string) => ResourceSnap **会话绑定的资源:`useResource` 挂会话标准件、身份为 `(session, address)`。** 第一版形态。被否,因为文件不是会话的事——会话只是路径的授权者——而且模型必须服务会话作用域之外的协议与组件。身份改为只有地址,作用域进入地址语法,hook 移到全局标准件。 -**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方按页读内容,这也是一个打开的 tab 能以一页的代价承载数兆字节文件的原因。 +**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方选择读取并保留多少内容。 **以抛错表达失败,并把非 `RemoteFailure` 的抛出包装成 `gateway/internal`。** 被否:Remote 面从不 reject,所以提供方抛出的任何东西都是 bug,包装它就是把 bug 藏起来不让肇事者看见的 fallback。失败是 `ok: false` 帧;抛出就冒出来。 @@ -71,17 +69,17 @@ type UseResource =

(address: string) => ResourceSnap **手写 scheme 前缀解析代替 URL 解析器。** 第一版 `protocolOf` 用正则匹配 scheme。地址成为 URL 后被否:解析器已经决定合法性与大小写,它拒绝的字串应读作「无协议」而不是被解析一半。 -**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上的 owner props 加一个按地址的客户端级 `useResource`。 +**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上框架绑定的 `useTabInfo` 加一个按地址的客户端级 `useResource`。 ## Consequences -任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、刷新或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。 +任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、正文重挂载或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。 代价记录在此以免被重新发现。记录不回收:内存随读过的不同地址数增长,而非随读取次数增长。中止合规归提供方;模型会丢弃已释放的流仍产出的帧,却阻止不了忽略信号的提供方跑到下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。导航地址或畸形字串读作 `none` 而非报错,这让混合地址列表渲染起来便宜,却让拼错的协议除了缺值之外没有任何诊断。 ## Testing -`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存、`reload` 转发。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。 +`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。 ## Deferred diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index e8267647e2..d08fb5f3f3 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md -2026-09-05-workspace-files-service.md: a95e083f8cea57b957a2c060fe1b7f2b76050153 -2026-09-05-workspace-files-service.zh.md: 59c638157f3e65efe3b89b220401434d1f2370b0 +2026-09-05-workspace-files-service.md: 39b73b71517934cf3007f042ac58061f655d6b85 +2026-09-05-workspace-files-service.zh.md: e4769a44a3517dffe36003e93cdeb3b258d6b443 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index a95e083f8c..39b73b7151 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -8,30 +8,32 @@ English | [中文](2026-09-05-workspace-files-service.zh.md) The Web client needs to look at files inside a session's workspace from a browser that may not be on the Host machine: a file the agent produced, the path a `read` tool row names, later a file tree and previews of files that are neither small nor text. The one endpoint that read a workspace file over the wire lived on the Session Controller as `workspace-file.ts`, beside session lifecycle it had nothing to do with. It returned a whole file under one total byte cap, so a large log could not be looked at even in part and a binary could not be looked at at all; it had no `stat`, no listing, and no change signal, so a preview could not learn that the agent had rewritten the file without re-reading it; and its result named the file by a Host `url`, a spelling nothing on the Client used as an address. -Two constraints frame any answer. Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend fences writes and edits only and says so — so a web-facing read endpoint must own every fence itself, and the fences must survive a symlink that leaves the workspace, which a string-prefix test cannot see. And `dsh-fs` exposed one raw-byte read, `readBytes(target, signal, maxBytes)`, which refuses any file longer than its cap: correct for an image the model ingests whole, useless for one window of a large file. +Two constraints frame the service. File reads through `ctx.fs` use the Session's composed filesystem backend, whose read authority may extend outside the workspace, while directory-tree and change-feed consumers are workspace-rooted. The service preserves the backend's read decisions for regular files while enforcing file-kind and bounded-buffer checks; `list` and `changes` retain workspace containment. And `dsh-fs` exposed one raw-byte read, `readBytes(target, signal, maxBytes)`, which refuses any file longer than its cap: correct for an image the model ingests whole, useless for one window of a large file. ## Decision -`packages/api/workspace-files` (`@deepseek-ai/dsh-api-workspace-files`) owns the Host `ctx.workspaceFiles` service, the `workspaceFiles` Remote namespace, and the Client `file` provider that turns `stat` and `changes` into live metadata for the [resource model](2026-09-05-client-resource-model.md); [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) governs their package organization. Every method confines itself to the workspace root the sandbox policy resolves for the addressed session, names files by their absolute path in the filesystem's execution world, and pages or windows content so that no method ever buffers a whole file. The byte window rides on a new `dsh-fs` seam, `FileSystem.readByteRange`, implemented by every provider. The Session Controller carries no workspace-file code. +`packages/api/workspace-files` (`@deepseek-ai/dsh-api-workspace-files`) owns the Host `ctx.workspaceFiles` service, the `workspaceFiles` Remote namespace, and the Client `file` provider that turns `stat` and `changes` into live metadata for the [resource model](2026-09-05-client-resource-model.md); [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) governs their package organization. File methods resolve relative paths from the workspace root but inherit the Session filesystem backend's read authority; `list` and `changes` remain workspace-scoped. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns this split and its security consequences. Results name files by their absolute path in the filesystem's execution world, and content is bounded by page, byte window, or complete-file cap. The byte window rides on a new `dsh-fs` seam, `FileSystem.readByteRange`, implemented by every provider. The Session Controller carries no workspace-file code. ### Package topology -[dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes this note's choice of separate Host and Client packages; the file service, authorization, paging, and change-feed decisions here remain in force. Host and Client compile in separate leaf configurations, share wire types, and the Client does not import the Host runtime entry. +[dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes this note's choice of separate Host and Client packages; the file service, paging, and change-feed decisions here remain in force. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) supersedes the original workspace-containment choice for file methods. Host and Client compile in separate leaf configurations, share wire types, and the Client does not import the Host runtime entry. | Face | Package | Files | Depends on | |---|---|---|---| | Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-agent`, `dsh-session` | | Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-api-session-controller/client`, `dsh-client-resources`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` | -`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI. +`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI. ### The `workspaceFiles` Remote namespace -Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The five signatures, as `src/index.ts` declares them: +Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The seven signatures, as `src/index.ts` declares them: ```ts ignore-check @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise @Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise @Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable @@ -40,25 +42,26 @@ Every Host method takes the target `Agent` first, resolved by the Gateway from t - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only. - **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream. - **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`. +- **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics. - **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered. - **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation. ### Paths on the wire -Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd. +Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd. `version` is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. `offset` means a line on `read` and a byte on `readBytes`; the two units never mix, and `eof` on either means the window reached the file's end. -### The four gates +### File checks and workspace containment -Every `read`, `readBytes`, `stat`, and `list` passes four gates in order, and the constraints are the service's own because the filesystem does not confine reads. The path is inspected before containment is decided, so a caller learns whether an outside path exists and what kind it is before `outside-workspace` refuses it; that is accepted because the caller is the Session's own owner, who can already read the Host through the Agent. +`read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`. -2. **Containment.** The path resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way (the session's cwd, falling back to the policy's configured root). A `..` traversal or an absolute path outside the root is `outside-workspace`. A string-prefix comparison is never used: `resolve` realpaths, so a prefix test cannot see a link that leaves the root. -3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. +2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets. +3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`. 4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected. -After the gates the file methods `stat` the target once more, because the file may have gone or changed kind between the inspection and the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. The gate order has one visible consequence: an entry outside the root whose type already disqualifies it reports its kind, not its position. +After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position. ### Failures @@ -67,20 +70,20 @@ Each failure is one `RemoteError` code with typed details, declared beside the t | Code | When | Details | |---|---|---| | `workspace-file/not-found` | no entry at the path, or the file vanished after the gates | `{ path }` | -| `workspace-file/outside-workspace` | the resolved target is not inside the workspace root | `{ path }` | -| `workspace-file/too-large` | a page's text or a requested byte window exceeds `maxBytes` | `{ path, limit }` | +| `workspace-file/outside-workspace` | a `list` target is not inside the workspace root | `{ path }` | +| `workspace-file/too-large` | a page's text or byte window exceeds `maxBytes`, or a complete read exceeds `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | invalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (`read` only) | `{ path }` | -| `workspace-file/not-regular-file` | `read`, `readBytes`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | `read`, `readBytes`, `readAll`, `readRelated`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | `list` on something that is not a directory | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client-minted: a resource address this provider cannot serve | `{ address }` | -| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address with no current Session | `{ address }` | +| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address, which carries no Session | `{ address }` | | `gateway/bad-request` | an empty path, or an `offset`, `limit`, or `length` that is not an integer in range | `{}` | The set is append-only: a code may be added, and none is renamed or removed, because consumers branch on these strings across the wire. ### Configuration -Three fields, all validated positive integers changeable from `cordis.yml`, and no other tunables: `maxBytes` (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; `maxLines` (default 5,000) is the default and largest page in lines; `maxEntries` (default 2,000) is the cap on returned directory entries. The file itself has no size cap: a caller pages or windows through it. +Four fields, all validated positive integers changeable from `cordis.yml`, and no other tunables: `maxBytes` (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; `maxLines` (default 5,000) is the default and largest page in lines; `maxEntries` (default 2,000) is the cap on returned directory entries; `maxFileBytes` (default 33,554,432, 32 MiB) caps complete and related-file reads. Paged and windowed reads impose no whole-file size cap. ### The `readByteRange` seam in `dsh-fs` @@ -96,27 +99,27 @@ It returns the bytes at `[offset, offset + length)`, shorter when the file ends ### The Client `file` provider -The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` for the plugin's lifetime and declares `ResourceProtocolMap.file`. The text-preview package registers this package's exported `WorkspaceFileParams` as `SidebarRightResourceParamsMap.file`. +The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` for the plugin's lifetime and declares `ResourceProtocolMap.file`. The Document Preview package registers this package's exported `WorkspaceFileParams` as `SidebarRightResourceParamsMap.file`. -- **The value is metadata**, `WorkspaceFileResource { absolutePath, version, bytes?, changed }`; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with `read` (or windows with `readBytes`) and uses `version` and `changed` to know when they are stale. -- **The address names the file; its scope selects the Session.** A `session` address's relative path reaches the Host unchanged for resolution and containment against that Session's workspace root; Client cwd is not a prerequisite. An `absolute` address reads through the current Session, failing with `workspace-file/unknown-workspace` when none is current. Unsupported grammar yields `workspace-file/unsupported-address`. These two Client errors end the stream and make reload a no-op. -- **The frames.** The first frame is a `stat` (`changed: false`) or its failure as an `ok: false` frame; the provider throws and catches nothing, because the Remote face never rejects and a throw inside a provider stream is a programming error left to surface. A Host write carrying a version the value does not hold yields `changed: true` with the byte count kept and no stat; a frame carrying the held version is dropped. A reported disappearance stats again — still there is fresh metadata flagged `changed`, gone is a `not-found` frame with the previous value left for display. `reload(address)` stats again and yields `changed: false`. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file, or a reload, brings the resource live. Aborting the signal ends the stream silently. +- **The value is metadata**, `WorkspaceFileStat { absolutePath, version, bytes? }`; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with `read` (or windows with `readBytes`) and compares versions to know when they are stale; freshness and refresh belong to each consumer, not the shared observation. +- **The address names the file; its scope selects the Session.** A `session` address's relative or absolute path reaches the Host unchanged; the workspace root supplies the base for a relative path, and the Session filesystem backend decides read access. Client cwd is not a prerequisite. An `absolute` address has no Session and fails with `workspace-file/unknown-workspace`; no current or tab Session is borrowed. Unsupported grammar yields `workspace-file/unsupported-address`. These two Client errors end the stream. +- **The frames.** The first frame is a `stat` or its failure as an `ok: false` frame; the provider throws and catches nothing, because the Remote face never rejects and a throw inside a provider stream is a programming error left to surface. A Host write carrying a version the value does not hold yields that version with the byte count kept and no stat; a frame carrying the held version is dropped. A reported disappearance stats again — still there is fresh metadata, gone is a `not-found` frame with the previous value left for display. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file brings the resource live. Aborting the signal ends the stream silently. - **One `changes` subscription per Session.** The first follower opens `remote.$stream`, the last release disposes it, and successor streams and plugin teardown await pending closes. The Client starts its first `stat` only after accepting Host `ready`; sending a local WebSocket request is not Host acknowledgement. A follower registers by address, queues changes before its path is known, then filters queued and live frames by the successful stat's `absolutePath`, normalizing backslashes to slashes. Any Session write can trigger a re-stat before the first successful binding. Gateway supervision reconnects carrier loss; Host end or terminal failure ends followers and retains their last metadata until reopened. - **Navigation parameters.** `SidebarRightResourceParamsMap.file` is `WorkspaceFileParams { line?: number }`, a 1-based line to reveal. A line travels as a navigation parameter and not as part of the address, because the file is one piece of content whether it opens at the top or at line 400. ### Related notes -The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. +The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same seven methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness. ## Alternatives considered -**Keeping the workspace file endpoint on the Session Controller.** The first form: one `read` under a total byte cap, registered as a sub-plugin of the Session Controller because that is where the wire entry already was. Rejected because a Workspace File service is its own capability — reading, statting, listing, and observing files inside a workspace root — and everything that queries workspace files belongs to it, while the Session Controller's concern is session lifecycle. The move also let the service grow to five methods without the Controller's file gaining a second purpose. +**Keeping the workspace file endpoint on the Session Controller.** The first form: one `read` under a total byte cap, registered as a sub-plugin of the Session Controller because that is where the wire entry already was. Rejected because a Workspace File service is its own capability — reading and statting files plus listing and observing the workspace — and those queries belong together, while the Session Controller's concern is session lifecycle. The move also let the service grow to five methods without the Controller's file gaining a second purpose. **A dual-face package with reverse UI dependencies.** The split-package choice followed two project-reference cycles after `api/remotes` referenced the Client leaf: the resource model imported Remote assembly for result types, and the file provider imported Sidebar UI for its parameter map. TypeScript rejected these cycles with `TS6202`. [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes that split: result types come directly from the protocol package, and Sidebar parameter registration belongs to the text preview; both root aggregates retain explicit compiler entries. **Serving workspace files over HTTP.** Already rejected by [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) on origin grounds and not revisited: `read` and `readBytes` carry plain text and base64 over the authenticated Remote carrier, so no document is served, no URL is minted, and no origin question arises. -**Log-reachable authorization for the read.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. Enough for produced files, but a typed path or a directory tree could never open. Path containment inside the workspace root was chosen, with the endpoint owning the constraints the filesystem's unconfined reads do not, and containment decided by `fs.contains` on resolved targets so a symlink cannot escape it. +**Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations. **Whole-file read and slice for the byte window.** The interim form of `readBytes` read the file from its start to the window's end through `readBytes(target, signal, offset + length)` and sliced. It cannot read a window of a file longer than that end — the seam refuses such a file as too large — so no window could ever report `eof: false`, which contradicts the reason the method exists. Rejected in favour of the `readByteRange` seam, whose bound is the window. @@ -124,28 +127,25 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, **A default `readByteRange` in the `FileSystem` base class.** A non-abstract default over `readBytes` would have spared the test doubles a method but could only be implemented by reading the whole file up to the window's end, the very behaviour rejected above, or by passing an unbounded cap. Abstract, with every provider and double implementing it. -**String-prefix containment.** Comparing resolved path strings against the root is simpler than `fs.contains`, but `resolve` realpaths, so a symlink that leaves the root resolves to a path outside it while a prefix test on the unresolved spelling passes; and a prefix test on the resolved spelling still needs the backend's notion of "same file". The filesystem decides containment. +**String-prefix containment for workspace operations.** Comparing resolved path strings against the root is simpler than `fs.contains`, but `resolve` realpaths, so a symlink that leaves the root resolves to a path outside it while a prefix test on the unresolved spelling passes; and a prefix test on the resolved spelling still needs the backend's notion of "same file". The filesystem decides containment for `list` and `changes`. ## Consequences - Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate. -- A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host and never a whole file; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines. +- A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host; complete reads instead enforce `maxFileBytes`; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines. - Every filesystem provider now offers a windowed raw read. `fs-e2b` pays for it by transferring the skipped prefix, since its SDK cannot seek; `fs-local` seeks. -- Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. An address built from another spelling of the same file — a workspace root reached through a symlink — opens and stats it, but its change frames never match, so `changed` stays false until a reload. +- Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. A follower binds to successful `stat.absolutePath`, so another spelling of the same file — a workspace root reached through a symlink — uses that canonical change key. - Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version. -- The gate order reports kind before position, a page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory, because a generation's queue is unbounded; each is a known trade-off recorded in the package README. +- File-kind inspection precedes backend reads, and `list` reports kind before an outside position. A page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory because a generation's queue is unbounded; each is a known trade-off recorded in the package README. - The `file` resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action. -- `readBytes` has no shipped consumer yet: it is the wire form the image and binary previews build on. +- `readBytes` has no shipped consumer yet; Document Preview uses `readAll` for complete-file formats. ## Testing -Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, `list` with truncation, symlink children, and `not-directory`, the `changes` stream driven by `fs/observed` and filtered by root, and every gate and code against a real local backend, because a fake filesystem would let a prefix test pass the symlink case the gate exists to catch. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, reload, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. +Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, outside-workspace reads and backend refusals, `list` with containment, truncation, symlink children, and `not-directory`, and the `changes` stream driven by `fs/observed` and filtered by root. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. `readAll` and `readRelated` specs cover complete-read caps, outside base and related paths, and Host backend authorization. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. ## Deferred -- A web e2e chain through the Sidebar: open a file, have the agent write it, see `changed`, reload. -- Aliasing a follower under the Host's canonical spelling once the first `stat` reveals it, so a symlinked workspace root still receives change frames. - A bound on a `changes` generation's queue. - The shipped consumer of `readBytes` (image and binary previews) and any write, search, or media route; the service is read-only. - Scopes other than `session` in the file address; the grammar leaves room, the provider serves one. -- Reload delivery per record: today `reload` re-stats every follower of the file's absolute path in the session, so two records naming one file — a `session` and an `absolute` address, or two readers with different addresses — clear each other's `changed` flag. diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 59c638157f..e4769a44a3 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -8,30 +8,32 @@ Status: implemented Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工作区里的文件:agent 产出的文件、`read` 工具行点名的路径,之后还有文件树,以及既不小也不是文本的文件预览。唯一一个经线路读取工作区文件的端点以 `workspace-file.ts` 住在 Session Controller 上,与它毫无关系的会话生命周期为邻。它在一个总字节上限之下返回整个文件,因此大日志连一部分都看不了、二进制根本看不了;它没有 `stat`、没有列举、没有变更信号,预览不重读就无法得知 agent 已改写文件;其结果还以 Host 的 `url` 命名文件,而 Client 上没有任何东西把这种拼法当地址用。 -两个约束框定了任何答案。经 `ctx.fs` 的读取是有意不受限的——沙箱后端只围栏写与编辑,并明说了这一点——所以面向 web 的读端点必须自己拥有每一道围栏,而且围栏必须经得住一条离开工作区的符号链接,这是字符串前缀测试看不见的。另外 `dsh-fs` 只暴露一种原始字节读取 `readBytes(target, signal, maxBytes)`,它拒绝任何比上限更长的文件:对模型整体摄入的图片是正确的,对大文件的一个窗口则毫无用处。 +两个约束框定了这项服务。经 `ctx.fs` 的文件读取使用 Session 组合后的文件系统后端,其读取权限可能延伸到工作区外,而目录树与变更流消费方以工作区为根。服务为普通文件保留后端的读取决策,同时执行文件类型与有界缓冲检查;`list` 与 `changes` 保留工作区包含限制。另外 `dsh-fs` 只暴露一种原始字节读取 `readBytes(target, signal, maxBytes)`,它拒绝任何比上限更长的文件:对模型整体摄入的图片是正确的,对大文件的一个窗口则毫无用处。 ## Decision -`packages/api/workspace-files`(`@deepseek-ai/dsh-api-workspace-files`)同时拥有 Host 服务 `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间,以及将 `stat` 与 `changes` 转成[资源模型](2026-09-05-client-resource-model.zh.md)实时元数据的 Client `file` 提供者;包组织方式由[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)规定。每个方法都把自己限制在沙箱策略为被寻址会话解析出的工作区根内,以文件在文件系统执行环境中的绝对路径命名文件,并对内容分页或开窗,因此没有任何方法会缓冲整个文件。字节窗口依托 `dsh-fs` 新增的 seam `FileSystem.readByteRange`,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。 +`packages/api/workspace-files`(`@deepseek-ai/dsh-api-workspace-files`)同时拥有 Host 服务 `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间,以及将 `stat` 与 `changes` 转成[资源模型](2026-09-05-client-resource-model.zh.md)实时元数据的 Client `file` 提供者;包组织方式由[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)规定。文件方法从工作区根解析相对路径,但继承 Session 文件系统后端的读取权限;`list` 与 `changes` 仍限于工作区。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)拥有这一分层及其安全后果。结果以文件在文件系统执行环境中的绝对路径命名文件,内容则受页、字节窗口或整文件上限约束。字节窗口依托 `dsh-fs` 新增的 seam `FileSystem.readByteRange`,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。 ### 包拓扑 -[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代本记录中把 Host 与 Client 分成两个包的组织选择;这里的文件服务、授权、分页和变更流约定保持不变。Host 与 Client 分别编译在两个叶配置中,共享线路类型,Client 不导入 Host 运行时入口。 +[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代本记录中把 Host 与 Client 分成两个包的组织选择;这里的文件服务、分页和变更流约定保持不变。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)取代文件方法原有的工作区包含选择。Host 与 Client 分别编译在两个叶配置中,共享线路类型,Client 不导入 Host 运行时入口。 | 面 | 包 | 文件 | 依赖 | |---|---|---|---| | Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-agent`、`dsh-session` | | Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-api-session-controller/client`、`dsh-client-resources`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` | -`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。 +`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。 ### `workspaceFiles` Remote 命名空间 -每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。五个签名照 `src/index.ts` 的声明: +每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。七个签名照 `src/index.ts` 的声明: ```ts ignore-check @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise @Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise @Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable @@ -40,25 +42,26 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。 - **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。 - **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。 +- **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的普通文件检查和后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。 - **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。 - **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。 ### 线路上的路径 -离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。 +离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。 `version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。 -### 四道关 +### 文件检查与工作区包含 -每次 `read`、`readBytes`、`stat` 与 `list` 依次过四道关,而这些约束是服务自己的,因为文件系统并不限制读取。路径先被检视再判定是否在工作区内,因此调用方在 `outside-workspace` 拒绝之前就能得知工作区外的路径是否存在、是何种类;这一点被接受,因为调用方就是 Session 的所有者,本来就能经 Agent 读 Host。 +`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。 -2. **包含关系。** 路径解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`(会话 cwd,退而取策略配置的根)。`..` 爬出或根外绝对路径为 `outside-workspace`。从不使用字符串前缀比较:`resolve` 会取 realpath,前缀测试看不见离开根的链接。 -3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。 +2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。 +3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。 4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。 -过关之后文件方法再对目标 `stat` 一次,因为在检查与读取之间文件可能已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。关的顺序有一个可见后果:根外条目若类型本身已不合格,报告的是其种类而不是其位置。 +路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置。 ### 失败 @@ -67,20 +70,20 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 | 代码 | 何时 | Details | |---|---|---| | `workspace-file/not-found` | 路径处无条目,或文件在过关后消失 | `{ path }` | -| `workspace-file/outside-workspace` | 解析出的目标不在工作区根内 | `{ path }` | -| `workspace-file/too-large` | 一页文本或所请求的字节窗口超过 `maxBytes` | `{ path, limit }` | +| `workspace-file/outside-workspace` | `list` 的目标不在工作区根内 | `{ path }` | +| `workspace-file/too-large` | 一页文本或字节窗口超过 `maxBytes`,或全文读取超过 `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | 到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 `read`) | `{ path }` | -| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes`、`readAll`、`readRelated` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | 对非目录执行 `list` | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client 铸出:本提供者无法服务的资源地址 | `{ address }` | -| `workspace-file/unknown-workspace` | Client 铸出:没有当前会话时的 `absolute` 地址 | `{ address }` | +| `workspace-file/unknown-workspace` | Client 铸出:不携带 Session 的 `absolute` 地址 | `{ address }` | | `gateway/bad-request` | 空路径,或不是范围内整数的 `offset`、`limit`、`length` | `{}` | 这个集合只增不改不删:可以新增代码,但不重命名、不移除任何一个,因为消费方跨线路按这些字符串分支。 ### 配置 -三个字段,都是可在 `cordis.yml` 中修改、经校验的正整数,此外没有其他可调项:`maxBytes`(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;`maxLines`(默认 5,000)是页的缺省与最大行数;`maxEntries`(默认 2,000)是返回目录条目数的上限。文件本身没有大小上限:调用方分页或开窗读完它。 +四个字段,都是可在 `cordis.yml` 中修改、经校验的正整数,此外没有其他可调项:`maxBytes`(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;`maxLines`(默认 5,000)是页的缺省与最大行数;`maxEntries`(默认 2,000)是返回目录条目数的上限;`maxFileBytes`(默认 33,554,432,即 32 MiB)限制全文及关联文件读取。分页和开窗读取不限制整个文件的大小。 ### `dsh-fs` 中的 `readByteRange` seam @@ -96,27 +99,27 @@ abstract readByteRange(target: FsTarget, range: { offset: number; length: number ### Client `file` 提供者 -Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存活期与插件相同,并声明 `ResourceProtocolMap.file`。文本预览包把本包导出的 `WorkspaceFileParams` 注册为 `SidebarRightResourceParamsMap.file`。 +Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存活期与插件相同,并声明 `ResourceProtocolMap.file`。Document Preview 包把本包导出的 `WorkspaceFileParams` 注册为 `SidebarRightResourceParamsMap.file`。 -- **值是元数据**,`WorkspaceFileResource { absolutePath, version, bytes?, changed }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并以 `version` 与 `changed` 得知它们何时过时。 -- **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对路径原样交给 Host,由 Host 按该会话的工作区根解析并检查包含关系,不要求 Client 持有 cwd。`absolute` 地址经当前会话读取,缺少当前会话时产生 `workspace-file/unknown-workspace`。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流,刷新无动作。 -- **帧。** 第一帧是 `stat`(`changed: false`)或其失败的 `ok: false` 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生 `changed: true`、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是标为 `changed` 的新元数据,不在则是保留上一个值供展示的 `not-found` 帧。`reload(address)` 再 stat 一次并产生 `changed: false`。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件或一次刷新会让资源恢复正常。中止 signal 则流静默结束。 +- **值是元数据**,`WorkspaceFileStat { absolutePath, version, bytes? }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并比较版本判断它们是否过时;新鲜度判断和刷新属于各消费方,不属于共享观察。 +- **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对或绝对路径原样交给 Host;工作区根为相对路径提供基准,Session 文件系统后端决定读取权限。Client 不需要持有 cwd。`absolute` 地址不带 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 tab 所属 Session。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流。 +- **帧。** 第一帧是 `stat`或其失败的 `ok: false` 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生该版本、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是新元数据,不在则是保留上一个值供展示的 `not-found` 帧。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件会让资源恢复正常。中止 signal 则流静默结束。 - **每会话一条 `changes` 订阅。** 首位跟随者打开 `remote.$stream`,最后一位离开时释放,后继流和插件拆除等待关闭完成。Client 接受 Host 的 `ready` 后才开始首次 `stat`;本地发出 WebSocket 请求不是 Host 确认。跟随者先按地址注册,缓冲路径未知期间的变更,成功 stat 后按返回的 `absolutePath` 过滤排队与实时帧,反斜杠归一为斜杠。尚未成功绑定时,Session 内任何写入均可触发重新 stat。载体掉线由 Gateway 监督器重连;Host 结束或终态失败会结束跟随者,并保留最近元数据,直到重新打开。 - **导航参数。** `SidebarRightResourceParamsMap.file` 是 `WorkspaceFileParams { line?: number }`,即要显露的 1 起算行号。行号作为导航参数而不是地址的一部分传递,因为不论从顶部还是第 400 行打开,文件都是同一份内容。 ### 相关记录 -[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的五个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。 +[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的七个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。 ## Alternatives considered -**把工作区文件端点留在 Session Controller 上。** 最初形态:总字节上限之下的一个 `read`,作为 Session Controller 的子插件注册,因为线路入口本来就在那里。被否,因为 Workspace File 服务是自己的能力——在工作区根内读取、stat、列举与观察文件——凡查询工作区文件的都归它,而 Session Controller 关心的是会话生命周期。搬出也让服务长到五个方法而不给 Controller 的文件添第二重目的。 +**把工作区文件端点留在 Session Controller 上。** 最初形态:总字节上限之下的一个 `read`,作为 Session Controller 的子插件注册,因为线路入口本来就在那里。被否,因为 Workspace File 服务是自己的能力——读取和 stat 文件,并列举和观察工作区——这些查询应归于一处,而 Session Controller 关心的是会话生命周期。搬出也让服务长到五个方法而不给 Controller 的文件添第二重目的。 **带有反向 UI 依赖的双面包。** 拆包选择源于 `api/remotes` 引用 Client 叶子后形成的两条工程引用环:资源模型为了结果类型引用 Remote 装配,文件提供者为了 Sidebar 参数表引用右栏 UI。TypeScript 以 `TS6202` 拒绝这些环。[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代拆包选择:结果类型直接取自协议包,Sidebar 参数注册移至文本预览;保留两个根聚合中的显式编译入口。 **经 HTTP 供工作区文件。** 已被[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)以 origin 理由否决且未重议:`read` 与 `readBytes` 经认证的 Remote 载体传送纯文本与 base64,因此不供文档、不铸 URL,也不产生 origin 问题。 -**读取的"日志可达"授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件。对产出文件够用,但手输的路径或目录树永远打不开。选择了工作区根内的路径包含,端点自行承担文件系统不受限读取所不具备的约束,并由 `fs.contains` 对已解析目标判定包含关系,使符号链接无法逃逸。 +**文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制。 **为字节窗口整文件读取再切片。** `readBytes` 的临时形态经 `readBytes(target, signal, offset + length)` 从文件开头读到窗口末端再切片。它读不了比该末端更长的文件的窗口——seam 会以过大拒绝这样的文件——因此没有任何窗口能报告 `eof: false`,与该方法存在的理由相悖。被否,改为以窗口为界的 `readByteRange` seam。 @@ -124,28 +127,25 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 **在 `FileSystem` 基类里给 `readByteRange` 一个默认实现。** 基于 `readBytes` 的非抽象默认能免去测试替身一个方法,但只能靠把文件从头读到窗口末端来实现——正是上文否决的行为——或者传一个无界上限。改为抽象方法,由每个提供者与替身实现。 -**字符串前缀包含判定。** 把解析后的路径字符串与根比较比 `fs.contains` 简单,但 `resolve` 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。由文件系统判定包含关系。 +**工作区操作使用字符串前缀包含判定。** 把解析后的路径字符串与根比较比 `fs.contains` 简单,但 `resolve` 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。`list` 与 `changes` 由文件系统判定包含关系。 ## Consequences - 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。 -- 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存、从不整文件;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。 +- 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存;全文读取则受 `maxFileBytes` 约束;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。 - 每个文件系统提供者现在都提供开窗的原始读取。`fs-e2b` 为此付出传输被跳过前缀的代价,因为其 SDK 不能 seek;`fs-local` 能 seek。 -- 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。由同一文件另一种拼法铸出的地址——经符号链接到达的工作区根——能打开并 stat 它,但其变更帧永不匹配,因此 `changed` 在刷新前保持 false。 +- 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。跟随者绑定到成功的 `stat.absolutePath`,因此同一文件的另一种拼法——经符号链接到达的工作区根——也使用该规范变更键。 - 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。 -- 关的顺序先报种类后报位置,页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。 +- 文件类型检查先于后端读取,`list` 也先报种类再报根外位置。页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。 - `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。 -- `readBytes` 尚无随包交付的消费方:它是图片与二进制预览赖以构建的线路形态。 +- `readBytes` 尚无随包交付的消费方;Document Preview 对整文件格式使用 `readAll`。 ## Testing -`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、带截断、符号链接子项与 `not-directory` 的 `list`、由 `fs/observed` 驱动并按根过滤的 `changes` 流,以及针对真实本地后端的每道关与每个代码——因为假文件系统会让前缀测试放过这道关本为捕获的符号链接场景。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、刷新、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 +`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、工作区外读取及后端拒绝、带包含限制、截断、符号链接子项与 `not-directory` 的 `list`,以及由 `fs/observed` 驱动并按根过滤的 `changes` 流。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。`readAll` 与 `readRelated` 的 spec 覆盖全文读取上限、工作区外基准与关联路径,以及 Host 后端授权。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 ## Deferred -- 一条经 Sidebar 的 web e2e 链:打开文件、让 agent 写它、看到 `changed`、刷新。 -- 在首次 `stat` 揭示 Host 的规范拼法后为跟随者加别名,使经符号链接的工作区根也能收到变更帧。 - 给 `changes` 一代流的队列加上限。 - `readBytes` 随包交付的消费方(图片与二进制预览)以及任何写入、搜索或媒体路由;本服务只读。 - 文件地址中 `session` 之外的作用域;语法留有余地,提供者只服务一个。 -- 按记录投递重载:今天 `reload` 重新 stat 该会话中此文件绝对路径的所有跟随者,因此命名同一文件的两条记录——`session` 与 `absolute` 地址,或地址不同的两个读者——会互相清掉 `changed` 标记。 diff --git a/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.i18n.yaml new file mode 100644 index 0000000000..a3a50d38ef --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md +2026-09-06-v3-canonical-session-envelopes.md: 17ec331cc1ea21f31d0b51eab86d22725b6deef8 +2026-09-06-v3-canonical-session-envelopes.zh.md: a18bb3b4d71c164e3fba7288cc686a1cb7c7b4df diff --git a/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md new file mode 100644 index 0000000000..17ec331cc1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md @@ -0,0 +1,47 @@ +# Agent Note: Canonical V3 Session event envelopes + +Status: implemented + +English | [中文](2026-09-06-v3-canonical-session-envelopes.zh.md) + +## Problem + +A Session event can cross in-memory, durable, and browser-wire readers. If its type permits missing placement or unrelated surface metadata, a reader can silently omit a message or disagree about which fields affect reconstruction. Multiple spellings for replacement endpoints and empty request-header optionals also allow different stored records to describe the same request. Contradictory tool failure metadata can make model history and diagnostics report different outcomes. + +## Decision + +Session format V3 uses one canonical event envelope. Every `system/message`, `user/message`, `assistant/message`, and `tool/result` requires `surfaceOp`. Known log-only events permit only `type`, `seq`, `time`, `data`, and optional `ignorable: true`; their TypeScript variants declare both surface metadata fields as optional `never`. Native unknown or obsolete ignorable envelopes remain opaque, including their metadata. Assistant messages embed their exact provider stream and alone forbid `sourceEventSeqs`. System, user, and tool messages may cite a non-empty, unique set of earlier source sequences. + +`SurfaceOp` is exactly `'append'` or `{ op: 'replace', startSeq, endSeq }`, with `SessionSeq` endpoints and no aliases or extra keys. Both endpoints precede the replacing event and identify an inclusive span in current surface order, not numeric sequence order. Session acceptance additionally verifies current membership, ordered endpoints, complete cited coverage, and content-only single-node tool-result replacement. Compaction payload fields such as `shadowedRange.start/end` and fold-result fields retain their own names; this is not a recursive payload rename. + +Current acceptance rejects every `request/header.header.system` and exactly empty `tools: []` or `adapterDefaults: {}`. System prompts belong to `system/message`; `request/header` remains the non-history request snapshot. Writers omit the two empty optionals. Whitespace-only system content, `config.stop: []`, nested header/source/data extras, and nested tool schema values remain intact. A `tool/result` with `data.error` requires `message.content[0].isError === true`; a failed result need not carry error identity. Neither current reads nor migration infer an error outcome from contradictory metadata. + +### Validation ownership + +[Core Session](../../../../packages/core/session/src/surface.ts) owns event-local placement, header-empty-field, and tool-error rules, while its surface manager owns relationships that need the event log. Seed, append, and restoration apply these rules before accepting events. They do not create a general schema for plugin-owned payloads or eagerly expand embedded provider streams. + +The generic Gateway client returns raw outputs without validating them. The existing [SessionEventStream](../../../../packages/api/session-controller/src/client/transport.ts) therefore checks follow snapshots, live durable entries, and history pages before publishing them. Its private [wire-event checker](../../../../packages/api/session-controller/src/client/session-wire-event.ts) validates the exact envelope and delegates event-local rules to the browser-safe core validators. It does not add a generic Gateway schema or validate unrelated plugin payloads. Surface membership and source existence remain Host-owned because a browser window may omit earlier events. + +### Released V2 to V3 conversion + +The [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) owns the complete historical conversion, its [canonicalization rules](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes), and [native admission and recovery](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission). Keeping these rules together prevents a cardinality-preserving canonicalization step from being mistaken for an identity migration. Frozen relationship validation uses private views rather than runtime aliases; the original V3 artifact remains authoritative. + +## Alternatives considered + +**Default missing placement to append.** This invents a model-history decision absent from the stored record and admits invalid V2 artifacts. Required placement keeps all readers accountable to the same evidence. + +**Accept both replacement spellings in current readers.** This preserves two durable representations and makes validation depend on which reader receives them. Only the adjacent edge interprets released keys; current readers accept V3 keys exclusively. + +**Normalize all empty values or repair tool outcomes.** Empty stop lists, whitespace, and plugin payloads can be meaningful. Removing them or setting `isError` from diagnostics changes recorded facts. The edge performs only named, semantics-preserving conversions and refuses contradictions. + +**Copy historical validators or pass V3 events directly to them.** Copying duplicates relationship semantics; direct reuse would accept obsolete envelope spellings and misinterpret system nodes and repair identities. Strict V3 validation followed by composed private views reuses frozen relationships without widening current acceptance. + +## Consequences + +Typed events, persistence, and browser history agree on required placement and event-local failure semantics. Malformed records fail before projection rather than disappearing from model history. Migration gives up best-effort recovery of contradictory records; retained source generations remain untouched under the [released-format publication policy](2026-08-31-released-session-format-migrations.md). + +This decision partially supersedes envelope representation details in the [session surface](2026-06-18-session-surface.md) and [reconstructable requests](2026-07-05-reconstructable-requests.md) notes. They remain active for ordered projection and logged request ownership. The [system-prompt surface-node decision](2026-09-02-system-prompt-as-surface-node.md) retains prompt ownership, protected-head semantics, and migration rationale. The [V2 embedded-stream decision](2026-09-01-v2-embedded-assistant-streams.md) remains active for attempt settlement, exact stream evidence, and cardinality-changing migration; V3 preserves those decisions. + +## Verification + +[Core acceptance tests](../../../../packages/core/session/tests/canonical-envelopes.spec.ts) pin invalid seed/append/restore records, typed surface variants, optional failure identity, and unchanged derived state after rejection. [Browser transport tests](../../../../packages/api/session-controller/tests/transport.client.spec.ts) exercise strict follow/page admission before publication. [Migration tests](../../../../packages/session/session-format-v2-to-v3/tests/canonical-envelopes.spec.ts) cover conversion and restoration; frozen adjacent-edge suites preserve historical semantics. Required coverage also includes codec admission, whitespace and empty stop-list preservation, opaque payload retention, and numerically descending replacement endpoints in valid surface order. diff --git a/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md new file mode 100644 index 0000000000..a18bb3b4d7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 规范的 V3 Session 事件信封 + +Status: implemented + +[English](2026-09-06-v3-canonical-session-envelopes.md) | 中文 + +## 问题 + +一个 Session 事件会经过内存、持久化与浏览器协议读取器。如果其类型允许缺少位置声明或携带无关 surface 元数据,读取器就可能静默遗漏消息,或对哪些字段影响重建产生分歧。替换端点的多种拼写与空请求头可选字段,也使不同存储记录能够描述同一请求。相互矛盾的工具失败元数据会让模型历史与诊断报告不同结果。 + +## 决策 + +Session 格式 V3 使用一种规范事件信封。每个 `system/message`、`user/message`、`assistant/message` 与 `tool/result` 都要求 `surfaceOp`。已知仅日志事件仅允许 `type`、`seq`、`time`、`data` 与可选的 `ignorable: true`;其 TypeScript 变体将两个 surface 元数据字段声明为可选 `never`。原生未知或已退役的可忽略信封(包括其元数据)保持不透明。assistant 消息嵌入精确提供方 stream,且只有此类消息禁止 `sourceEventSeqs`。system、user 与 tool 消息可以引用非空、唯一的较早来源序号集合。 + +`SurfaceOp` 恰好为 `'append'` 或 `{ op: 'replace', startSeq, endSeq }`,端点使用 `SessionSeq`,不接受别名或额外键。两个端点都早于替换事件,并按当前 surface 顺序而非数值序号顺序标识闭区间。Session 接纳还验证当前成员关系、端点顺序、完整引用覆盖与仅修改内容的单节点工具结果替换。`shadowedRange.start/end` 等压缩(compaction)载荷字段与折叠结果字段保留各自名称;这不是对载荷进行递归重命名。 + +当前接纳拒绝任何 `request/header.header.system` 以及恰好为空的 `tools: []` 或 `adapterDefaults: {}`。系统提示词属于 `system/message`;`request/header` 仍是请求非历史状态的快照。写入方省略两个空可选字段。仅含空白的系统内容、`config.stop: []`、嵌套 header/source/data 扩展与嵌套工具 schema 值保持原样。带有 `data.error` 的 `tool/result` 要求 `message.content[0].isError === true`;失败结果不必携带错误身份。当前读取与迁移均不会根据矛盾元数据推断错误结果。 + +### 校验所有权 + +[核心 Session](../../../../packages/core/session/src/surface.ts)负责事件本地的位置、请求头空字段与工具错误规则,其 surface 管理器负责需要事件日志的关系。seed、append 与恢复会在接纳事件前应用这些规则。它们不会为插件自有载荷创建通用 schema,也不会提前展开嵌入式提供方 stream。 + +通用 Gateway 客户端返回未经校验的原始输出。因此,现有 [SessionEventStream](../../../../packages/api/session-controller/src/client/transport.ts) 会在发布前检查 follow 快照、实时持久条目与历史页。其私有[协议事件检查器](../../../../packages/api/session-controller/src/client/session-wire-event.ts)验证精确信封,并将事件本地规则委托给可在浏览器中使用的核心校验器。它不添加通用 Gateway schema,也不校验无关插件载荷。surface 成员关系与来源是否存在仍由 Host 负责,因为浏览器窗口可能未包含较早事件。 + +### 已发布 V2 到 V3 的转换 + +[V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)负责完整历史转换、[规范化规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)及[原生准入与恢复](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)。将这些规则集中在一起,可以避免把保持事件数量的规范化步骤误认为恒等迁移。冻结的关系校验使用私有视图而非运行时别名;原始 V3 产物仍具权威性。 + +## 曾考虑的替代方案 + +**将缺失的位置默认为 append。** 这会凭空添加存储记录中不存在的模型历史决策,并接纳无效 V2 产物。要求位置声明,使所有读取器必须依据同一证据。 + +**当前读取器接受两种替换拼写。** 这会保留两种持久表示,并使校验取决于接收记录的读取器。只有相邻迁移边解释已发布的键;当前读取器只接受 V3 键。 + +**规范化所有空值或修复工具结果。** 空 stop 列表、空白与插件载荷可能有意义。删除它们或根据诊断设置 `isError` 会改变已记录事实。迁移边只执行具名且保持语义的转换,并拒绝矛盾。 + +**复制历史校验器或直接向其传入 V3 事件。** 复制会重复关系语义;直接复用则会接受旧信封拼写,并误解系统节点与修复身份。严格的 V3 校验加组合后的私有视图,可以在不扩大当前接纳范围的前提下复用冻结关系。 + +## 后果 + +类型化事件、持久化与浏览器历史对必填位置和事件本地失败语义保持一致。畸形记录在投影前失败,而不会从模型历史中消失。迁移放弃对矛盾记录的尽力恢复;[已发布格式的发布策略](2026-08-31-released-session-format-migrations.zh.md)保证保留的源代次不被修改。 + +本决策部分取代[会话 surface](2026-06-18-session-surface.zh.md)与[可重建请求](2026-07-05-reconstructable-requests.zh.md)说明中的信封表示细节。它们继续负责有序投影与已记录请求的所有权。[系统提示词 surface 节点决策](2026-09-02-system-prompt-as-surface-node.zh.md)保留提示所有权、受保护头节点语义与迁移依据。[V2 嵌入式 stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)继续负责尝试结算、精确 stream 证据与改变事件数量的迁移;V3 保留这些决策。 + +## 验证 + +[核心接纳测试](../../../../packages/core/session/tests/canonical-envelopes.spec.ts)固定无效 seed/append/restore 记录、类型化 surface 变体、可选失败身份与拒绝后派生状态不变。[浏览器传输测试](../../../../packages/api/session-controller/tests/transport.client.spec.ts)检验发布前的严格 follow/page 接纳。[迁移测试](../../../../packages/session/session-format-v2-to-v3/tests/canonical-envelopes.spec.ts)覆盖转换与恢复;冻结的相邻迁移边测试保留历史语义。必需覆盖还包括编解码器接纳、空白与空 stop 列表保留、不透明载荷保留,以及按合法 surface 顺序排列但数值递减的替换端点。 diff --git a/.agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.i18n.yaml new file mode 100644 index 0000000000..c11c54f6a9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.md +2026-09-07-prebuilt-system-primitives.md: 183cd3ea984cd779417493f0de17a770f38fc001 +2026-09-07-prebuilt-system-primitives.zh.md: f051bba92910086601aa48a877f360e276df524d diff --git a/.agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.md b/.agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.md new file mode 100644 index 0000000000..183cd3ea98 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.md @@ -0,0 +1,33 @@ +# Agent Note: Prebuilt system primitives + +Status: implemented + +English | [中文](2026-09-07-prebuilt-system-primitives.zh.md) + +## Problem + +The JSONL writer's `fs-ext` dependency compiled a NAN addon during consumer installation. Native compiler availability and Node module ABI changes therefore affected ordinary installs, including Node 26. The repository already maintained the Landlock launcher and its per-platform publication workflow. + +## Decision + +The independently versioned `@deepseek-ai/node-addon-system` family in [native/system](../../../../native/system/README.md) distributes the existing `landlock-run` executable and a stable Node-API v8 `system.node` addon. Platform packages select OS and CPU; Linux carries distinct glibc and musl addon files. macOS carries the addon without a Landlock executable. Neither the entry nor platform packages compile during installation. + +The package has no root export. The `./landlock-run` JavaScript entry retains Landlock's API and [CLI protocol](../../../../native/system/docs/cli-contract.md). The `./flock` entry loads its addon only when `tryLockExclusive(fd)` is called. It runs `flock(fd, LOCK_EX | LOCK_NB)` in asynchronous native work and captures errno on that worker. The caller owns the descriptor through completion and releases its lock by closing it. Missing bindings reject acquisition rather than granting an unprotected lock. + +The [Session write-lease decision](../feature/2026-08-31-cross-process-session-write-lease.md) continues to own acquisition timing, inode checks, close ownership, and crash semantics. Windows retains its existing koffi semaphore. The browser worker substitutes only the flock subpath; it uses the unchanged `./landlock-run` JavaScript API. + +Source builds explicitly compile the host addon before repository tests and builds that need it. Native CI builds the complete platform payload and tests the same addon bytes across Node releases; Linux also exercises the musl payload in Alpine. Platform prepack rejects malformed or incomplete binaries, and an offline npm install rehearsal checks installed bytes and real lock behavior. Native [tests](../../../../native/system/test/flock.test.js) cover descriptor/process contention, close and crash release, independent errno values, and worker teardown. + +## Alternatives considered + +**Keep NAN and publish one build per Node ABI.** This retains a Node-major build matrix for a binding that needs only stable Node-API operations. The evaluated `fs-ext-extra-prebuilt@2.2.14` selected a Node 25 ABI 141 binary under Node 26 ABI 147; its default-install fallback also exited without building when NAN was hoisted. + +**Bundle fs-ext into the parent tarball.** npm normally still runs bundled dependency installation hooks. Bundling alone neither suppresses compilation nor makes one binary portable across operating systems, CPUs, libc implementations, or Node ABIs. + +**Replace flock with OFD/fcntl locks.** On ordinary Linux filesystems these locks do not necessarily exclude existing flock holders. A tmpfs probe admitted an OFD lock while fs-ext held flock, so this is not a behavior-preserving replacement. + +**Use koffi for the POSIX call.** A synchronous call changes event-loop blocking behavior; reading errno after its asynchronous callback reads the wrong thread's value. A native async-work adapter keeps the syscall result and errno together without another FFI coordination layer. + +## Consequences + +The family owns a small C binding, platform builds, and installed-artifact verification rather than an entire filesystem-extension API. Node-API removes the per-Node-major binary requirement, not OS/CPU/libc requirements. The shared native release includes both capabilities, but importing or using one does not load the other. Landlock binary semantics, Windows locking, and released Session data formats remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.zh.md b/.agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.zh.md new file mode 100644 index 0000000000..f051bba929 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 预编译系统原语 + +Status: implemented + +[English](2026-09-07-prebuilt-system-primitives.md) | 中文 + +## Problem + +JSONL 写入方依赖的 `fs-ext` 在用户安装时编译 NAN addon。因此,原生编译器是否可用以及 Node 模块 ABI 的变化会影响普通安装,包括 Node 26。仓库已经维护了 Landlock 启动器及其按平台发布的工作流。 + +## Decision + +[native/system](../../../../native/system/README.zh.md) 中独立版本的 `@deepseek-ai/node-addon-system` 包族分发既有 `landlock-run` 可执行文件和使用稳定 Node-API v8 的 `system.node` addon。平台包按操作系统和 CPU 选择;Linux 分别携带 glibc 与 musl addon 文件。macOS 携带 addon,但不包含 Landlock 可执行文件。入口包和平台包都不在安装期间编译。 + +包不提供根导出。`./landlock-run` JavaScript 入口保留 Landlock API 和 [CLI 协议](../../../../native/system/docs/cli-contract.md)。`./flock` 入口仅在调用 `tryLockExclusive(fd)` 时加载 addon。它在异步原生工作中执行 `flock(fd, LOCK_EX | LOCK_NB)`,并在该工作线程保存 errno。调用方在完成前持有描述符,并通过关闭它释放锁。绑定缺失时拒绝获取锁,不授予没有保护的锁。 + +[Session 写租约决策](../feature/2026-08-31-cross-process-session-write-lease.zh.md) 继续负责获取时机、inode 校验、关闭所有权和崩溃语义。Windows 保留既有 koffi 信号量。浏览器 worker 仅替换 flock 子路径,使用未经修改的 `./landlock-run` JavaScript API。 + +源码构建在需要 addon 的仓库测试与构建之前显式编译当前宿主 addon。Native CI 构建完整平台产物,并让相同 addon 字节跨 Node 版本测试;Linux 还在 Alpine 中执行 musl 产物。平台 prepack 拒绝格式错误或不完整的二进制,离线 npm 安装演练检查安装字节与真实锁行为。Native [测试](../../../../native/system/test/flock.test.js) 覆盖描述符与进程竞争、关闭和崩溃释放、独立 errno 值及 worker 清理。 + +## Alternatives considered + +**保留 NAN,为每个 Node ABI 发布构建。** 这会为仅需稳定 Node-API 操作的绑定保留 Node 主版本构建矩阵。已评估的 `fs-ext-extra-prebuilt@2.2.14` 在 Node 26 ABI147 下选中 Node 25 ABI141 二进制;默认安装回退还会在 NAN 被提升安装时提前退出而不编译。 + +**将 fs-ext 打入父包 tarball。** npm 默认仍执行 bundled 依赖的安装钩子。仅打包既不能禁止编译,也不能让一个二进制跨操作系统、CPU、libc 实现或 Node ABI 通用。 + +**将 flock 换成 OFD/fcntl 锁。** 在普通 Linux 文件系统上,这些锁不一定排斥既有 flock 持有者。tmpfs 探针在 fs-ext 持有 flock 时仍取得 OFD 锁,因此这不是保持行为的替换。 + +**通过 koffi 执行 POSIX 调用。** 同步调用改变事件循环的阻塞行为;在异步回调后读取 errno 会读到错误线程的值。原生 async-work 适配器把系统调用结果和 errno 保存在一起,无须另加 FFI 协调层。 + +## Consequences + +包族维护小型 C 绑定、平台构建和安装产物验证,而不是整套文件系统扩展 API。Node-API 消除按 Node 主版本分发二进制的要求,但不消除操作系统、CPU 和 libc 要求。统一原生发布包含两项能力,但导入或使用其中一项不会加载另一项。Landlock 二进制语义、Windows 锁和已发布 Session 数据格式保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml new file mode 100644 index 0000000000..70fc2a365d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +2026-09-08-document-preview-operations.md: 316f33371d393846cce5b598ee23289cecd6c178 +2026-09-08-document-preview-operations.zh.md: 97759db32d139a44fe33fd2c5e2eb7ec0c8960fd diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md new file mode 100644 index 0000000000..316f33371d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -0,0 +1,39 @@ +# Agent Note: Document preview and file addresses + +Status: implemented + +English | [中文](2026-09-08-document-preview-operations.zh.md) + +## Problem + +File viewers need different loading policies and may offer several implementations for one extension. A change stream cannot also express an on-demand read without mixing live data with callable capabilities. HTML dependencies additionally need the Host's filesystem authorization and path resolution, not the browser's current directory. + +## Decision + +Document Preview separates resource observation from content reads. The [resource model](2026-09-05-client-resource-model.md) shares observations by address alone: `source(address)`, `pin(address, signal)`, and provider `open(address, { signal })` carry no consuming Session. Providers return `AsyncIterable>`; `useResource` exposes only `{ status, value, failure }`. Holds start and stop observation, not the underlying file or Session. Content reads use ordinary injected Preview callbacks. + +[Workspace Files](../../../../packages/api/workspace-files/README.md) retains Host line reads, byte windows, bounded complete reads, and bounded reads relative to another file's directory. Its Client `file` provider observes only `stat` and `changes`, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. The Host resolves every path through the Session filesystem; file reads inherit that backend's read authority, while directory listing and change observation stay workspace-scoped. + +Readable files use `dsh-resource://file/session//`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity. + +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives and remembers a manual choice per tab; plain text is the fallback. The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. + +Markdown and code reuse the incremental primitives with cumulative paged text. HTML and PDF read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. Replacing the document replaces the browsing context and revokes its root Blob. + +## Alternatives considered + +**Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads. + +**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview. + +**UI Session as extra Resource identity, or authorization from the first holder or current selection.** A retained tab can belong to a different Session from the selected one, and the UI location does not identify the addressed file. Encoding the required Session in the file address preserves Host authorization while letting all readers of one address share observation. + +**File-reading methods on every resource.** Chat and terminal resources have independent data and operation semantics; only observation registration and lifetime are common. + +**A preview resource wrapper, content Session, or second resource Hook.** These duplicate addressing, cancellation, subscriptions, and ownership already provided by Resource and Workspace Files. Loading policy belongs to the preview owner. + +**A local server, virtual host, or `file:` iframe.** These require extra hosting or filesystem authority. The preview is for static generated pages, not a complete application runtime; modules, dynamic filesystem requests, and arbitrary nested asset graphs are outside its support. + +## Consequences + +Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, and lazy continuous PDF Worker rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md new file mode 100644 index 0000000000..97759db32d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 文档预览与文件地址 + +Status: implemented + +[English](2026-09-08-document-preview-operations.md) | 中文 + +## 问题 + +文件预览器需要不同的加载策略,同一扩展名也可能对应多种实现。变更流无法同时表达按需读取而又不把实时数据与可调用能力混在一起。HTML 依赖还需要 Host 的文件系统授权和路径解析,而非浏览器的当前目录。 + +## 决策 + +Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-05-client-resource-model.zh.md)只按地址共享观察:`source(address)`、`pin(address, signal)` 和提供方的 `open(address, { signal })` 均不携带消费 Session。提供方返回 `AsyncIterable>`;`useResource` 只暴露 `{ status, value, failure }`。持有只控制观察的启停,不控制底层文件或 Session 的生灭。内容通过普通注入的 Preview 回调读取。 + +[Workspace Files](../../../../packages/api/workspace-files/README.zh.md) 保留 Host 的行读取、字节窗口、有上限的全文读取和相对另一文件目录的有界读取。Client `file` 提供方只观察 `stat` 与 `changes`,`ResourceProtocolMap.file` 直接为 `WorkspaceFileStat`。Host 通过 Session 文件系统解析每条路径;文件读取继承该后端的读取权限,目录列举与变更观察仍限定于工作区。 + +可读取的文件使用 `dsh-resource://file/session//`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。 + +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 + +Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 PDF 读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。替换文档会替换浏览上下文,并撤销其根 Blob。 + +## 考虑过的替代方案 + +**把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。 + +**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。 + +**把 UI Session 作为额外 Resource 身份,或由首个持有者、当前选择决定授权。** 保留的 tab 可以属于不同于当前选择的 Session,UI 所在位置也不能标识地址指向的文件。将所需 Session 编入文件地址,既保留 Host 授权,也让同地址的所有读者共享观察。 + +**让所有资源提供文件读取方法。** Chat 与终端资源的数据和操作语义各自独立,只有观察的注册和生命周期是共用机制。 + +**预览资源包装层、内容 Session 或第二个资源 Hook。** 这些方案重复了 Resource 和 Workspace Files 已提供的寻址、取消、订阅和归属。加载策略属于预览所有者。 + +**本地服务器、虚拟主机或 `file:` iframe。** 这些方案需要额外托管或文件系统权限。预览面向静态生成页面,而非完整应用运行时;模块、动态文件系统请求和任意嵌套资源图不在支持范围内。 + +## 影响 + +替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖,以及惰性连续 PDF Worker 渲染。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml new file mode 100644 index 0000000000..f5b5d88586 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md +2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3 +2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89 diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md new file mode 100644 index 0000000000..75be68ac1b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md @@ -0,0 +1,37 @@ +# Agent Note: Global main panels without default UI additions + +Status: implemented + +English | [中文](2026-09-08-global-main-panels.zh.md) + +## Problem + +Plugins need application-wide views that do not belong to a Session. A Session-scoped Conversation view cannot provide that lifetime, and replacing the Conversation's single slot removes the ordinary conversation surface. Adding this extension must not add navigation controls or reserved space to the default application. + +## Decision + +The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding. + +The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel. + +One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame. + +`uiWorkspace.openSession(id)` selects the Session before returning the main area to the Conversation, including when the same Session is selected again. `openWorkspace` and `forkSession` use the layout's `beginNavigation()` abort signal and their own service lifetime to commit only the latest navigation. The Workspace preparation callback moves drafts synchronously only while the request remains current. Supersession prevents a late UI commit, not Session creation. Panel navigation neither cancels the retained Session nor writes a Session event. + +DOM focus is not navigation selection. Search and directory-picker controls can receive focus while the global panel and its selected sidebar row remain visible; opening a Session changes the main selection. + +## Alternatives considered + +**Session-scoped main views.** Their lifetime and standard props bind application-wide state to whichever Session happens to be current. + +**A second navigation stack.** Back buttons and saved return destinations are unnecessary when New Session and workspace Session rows already provide explicit destinations. + +**React title slots.** Navigation entries use the same plain label for visible text and accessibility; a separate title registration is outside that presentation. + +**Flat selection and layout state with shallow comparison.** Separating the two stored objects preserves reference equality directly and avoids allocating and comparing a fresh layout projection on every panel selection. + +## Consequences + +The default sidebar snapshots remain unchanged. Extension panels have no right Sidebar, and selecting a different global panel does not change layout preferences. Switching between a Conversation with a visible right Sidebar and a global panel still changes the required column widths; this is not a promise of zero browser layout work. + +Panel selection is transient and resets on reload. Plugin disposal removes its contributions; removing the selected main entry returns the main area to the Conversation. Tests register real temporary panels and cover row interaction, focus, independent stored references, invalid ids, superseded asynchronous navigation, declaration lifetimes, and the empty default sidebar. The [Slots reference](../../../../docs/subsystems/slots.md) owns the composition API. diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md new file mode 100644 index 0000000000..343f183367 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 不增加默认界面的全局主面板 + +Status: implemented + +[English](2026-09-08-global-main-panels.md) | 中文 + +## 问题 + +插件需要不属于任何会话的应用级视图。会话作用域的 Conversation 视图无法提供这种生命周期,而替换 Conversation 的 single slot 又会移除普通会话界面。增加此扩展不能在默认应用中增加导航控件或预留空间。 + +## 决策 + +布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。 + +侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。 + +渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。 + +`uiWorkspace.openSession(id)` 先选中会话,再将中央区域切回 Conversation,包括再次选中同一个会话的情况。`openWorkspace` 和 `forkSession` 使用布局的 `beginNavigation()` abort signal 与自身 service 生命周期,只提交最新导航。工作区准备回调仅在请求仍有效时同步搬移草稿。请求过期会阻止晚到的 UI 提交,但不阻止会话创建。面板导航既不取消保留的会话,也不写入会话事件。 + +DOM 焦点不是导航选中态。搜索和目录选择控件可以获得焦点,同时保留全局面板及其侧栏行的选中态;打开会话才改变中央区域的选中态。 + +## 考虑过的替代方案 + +**会话作用域的主视图。** 其生命周期和标准 props 会把应用级状态绑定到恰好处于当前态的会话。 + +**另一套导航栈。** 新会话和工作区会话行已经提供明确目标,不需要返回按钮或保存返回目的地。 + +**React 标题 slot。** 导航条目的可见文字与无障碍名称使用同一个普通标签;独立的标题注册不属于这一呈现方式。 + +**平铺选中态和布局状态,再做浅比较。** 将两者存为独立对象可以直接保持引用相等,避免每次选择面板都分配并比较新的布局投影。 + +## 后果 + +默认侧栏快照保持不变。扩展面板没有右侧 Sidebar,选择另一个全局面板不会改变布局偏好。在显示右侧 Sidebar 的 Conversation 与全局面板之间切换时,所需列宽仍会变化;这并不保证浏览器完全不计算布局。 + +面板选中态是瞬时状态,刷新后重置。插件 dispose(资源释放)会移除其贡献;移除当前选中的主面板条目会使中央区域回到 Conversation。测试注册真实临时面板,覆盖行交互、焦点、存储引用的独立性、无效 id、过期异步导航、声明生命周期和默认空侧栏。[Slots 参考](../../../../docs/subsystems/slots.zh.md)拥有组合 API 的说明。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml new file mode 100644 index 0000000000..e4d4d80da4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md +2026-09-09-workspace-file-read-authority.md: 35ac15f5e463910be3b3a0cfe7b80dc69c129ad3 +2026-09-09-workspace-file-read-authority.zh.md: 559a59af0bfa988ff880fad22470397e4e74f296 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md new file mode 100644 index 0000000000..35ac15f5e4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md @@ -0,0 +1,29 @@ +# Agent Note: Workspace file read authority + +Status: implemented + +English | [中文](2026-09-09-workspace-file-read-authority.zh.md) + +## Problem + +Workspace Files serves both file content and workspace navigation. Applying workspace containment to every operation creates a second read policy above the Session filesystem backend and prevents a user from previewing paths that the same Session can read outside its workspace. HTML preview also needs direct relative JavaScript and stylesheet files, including `..` paths, while its script-enabled document can use the browser network. + +## Decision + +`read`, `readBytes`, `readAll`, `readRelated`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps. + +`list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate. + +`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. + +The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox. + +## Alternatives considered + +**Contain every operation within the workspace.** This gives previews a narrower policy than the Session filesystem backend, blocks explicitly addressed readable files, and prevents HTML beside external assets from rendering. Workspace containment remains where the operation itself represents the workspace. + +**Permit outside reads but block all iframe networking.** A stricter CSP would reduce exfiltration risk, but it would also reject external assets and network behavior intentionally retained for the static-HTML preview. The opaque sandbox protects the parent application; it does not promise network isolation. + +## Consequences + +Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. A previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates. diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md new file mode 100644 index 0000000000..559a59af0b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 工作区文件读取权限 + +Status: implemented + +[English](2026-09-09-workspace-file-read-authority.md) | 中文 + +## Problem + +Workspace Files 同时提供文件内容与工作区导航。对所有操作应用工作区包含限制,会在 Session 文件系统后端之上形成第二套读取策略,并阻止用户预览同一 Session 在工作区外可读的路径。HTML 预览还需要直接读取相对 JavaScript 与样式表文件,包括含 `..` 的路径,而启用脚本的文档可以使用浏览器网络。 + +## Decision + +`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。 + +`list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。 + +`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 + +[Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。 + +## Alternatives considered + +**把所有操作限制在工作区内。** 这会让预览采用比 Session 文件系统后端更窄的策略,阻止读取明确寻址的可读文件,并使位于外部资源旁的 HTML 无法渲染。操作本身代表工作区时,仍保留工作区包含限制。 + +**允许根外读取,但阻断 iframe 的全部网络。** 更严格的 CSP 可以降低数据外传风险,但也会拒绝静态 HTML 预览有意保留的外部资源与网络行为。不透明 sandbox 保护父应用,但不承诺网络隔离。 + +## Consequences + +持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml new file mode 100644 index 0000000000..43f6f3b14c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md +2026-09-07-pi-ai-settings-catalog-recovery.md: 21fe532a491775ff875f6b9bcb000d917a95e13c +2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 80bd0758e13b767671f4cec52ee3832c9250651f diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md new file mode 100644 index 0000000000..21fe532a49 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md @@ -0,0 +1,39 @@ +# Agent Note: Repairable pi-ai settings after catalog changes + +Status: implemented + +English | [中文](2026-09-07-pi-ai-settings-catalog-recovery.zh.md) + +## Problem + +An installed pi-ai catalog can change the validity of unchanged user settings. OpenRouter models outside the catalog can inherit a protocol while all shipped models agree; adding a second protocol removes that inference. Removing a catalog model also invalidates an override keyed by its former id. Rejecting the entire settings namespace at registration makes unrelated providers disappear and removes the controls needed to repair the configuration. + +## Decision + +The pi-ai consumer uses the existing settings `validate` callback. During namespace registration it tolerates catalog diagnostics; after registration it strictly checks changed providers against the current resolved section. Settings invokes this callback before persistence for update, replacement, and path mutation. External reload uses the same strict check and retains the last accepted section on failure. The settings service and its public API remain unchanged. + +Initial profile resolution retains catalog diagnostics, while schema and self-contained profile constraints still reject loading. Writes strictly resolve each new or changed provider, comparing effective provider values against the committed snapshot. Unchanged failed providers do not block another provider's edit, and deletion remains possible. Editing a provider-wide setting validates all models it affects. + +Profile resolution keeps valid models beside per-model errors. A missing override retains its diagnostic without disabling the remaining catalog. A route-level catalog failure retains its provider and editable settings but supplies no callable models. When route-wide validation aborts catalog resolution, the incomplete catalog and its collected per-model diagnostics are discarded; model requests on that route report the route-level error. The adapter checks the selected model's recorded failure before credentials or network I/O and reports `INVALID_CONFIG`. No protocol is guessed and no user configuration is rewritten during loading. Immutable snapshots still keep an in-flight request on its captured configuration. + +`LlmConfigurableProvider.error` carries the first available model diagnostic for the provider row, falling back to the route error. A provider-construction failure does not overwrite a collected model diagnostic, preserving the specific correction for a missing protocol. The configurable-provider directory publishes diagnostic changes so configuration repair refreshes the browser without re-registering the adapter. Failed model ids remain in settings, while the model selector receives serviceable entries. Models settings displays the diagnostic and retains edit/delete controls. Both add actions require their owning settings namespace; the ordinary add menu filters out unavailable namespaces. + +This extends the [provider-routed adapter decision](../architecture/2026-07-14-provider-routed-llm-adapters.md): provider ownership and request snapshots remain unchanged, while catalog validity does not determine whether settings can be managed. That note remains active for routing, ownership, and replay rationale. + +## Alternatives considered + +**Reject catalog errors at registration.** This prevents users from repairing an otherwise parseable configuration and lets an unused stale model disable unrelated providers. + +**Relax save validation too.** A newly entered model with no inferable protocol can be rejected immediately with the offending provider and model named. Accepting it creates an avoidable request-time failure. + +**Strictly revalidate the entire namespace on every save.** An unrelated provider's old error would block adding a healthy provider or repairing providers independently. + +**Assign OpenRouter a fixed route protocol.** A route override replaces every model's protocol and can change working catalog entries that intentionally use another API. + +## Consequences + +Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. Models settings does not explain namespace registration failures; those errors require inspecting the configuration and startup diagnostics. No settings API, storage format, or session event is added; configurable-provider entries gain one optional diagnostic field. + +## Testing + +Adapter tests cover mixed valid/invalid models, deleted override referents, independent provider edits, route deletion, pre-network failure, and repair. A file-watcher regression verifies that invalid external edits retain the last accepted profiles and a repaired file takes effect. The assembled Web expectation boots with stale OpenRouter settings, preserves zai and both add controls, rejects an invalid save without changing the file, and repairs the route by removing the stale model. Existing snapshot tests continue to own request freezing and replay behavior. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md new file mode 100644 index 0000000000..80bd0758e1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md @@ -0,0 +1,39 @@ +# Agent Note: pi-ai 目录变化后可修复的设置 + +Status: implemented + +[English](2026-09-07-pi-ai-settings-catalog-recovery.md) | 中文 + +## Problem + +已安装的 pi-ai 目录可能改变未修改用户设置的有效性。OpenRouter 的目录外模型可以在所有内置模型协议一致时继承协议;加入第二种协议会使这种推断失效。删除目录模型也会使按其旧 ID 声明的覆盖失效。注册时拒绝整个 settings 命名空间,会让无关提供方消失,也移除了修复配置所需的控件。 + +## Decision + +pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期间容忍目录诊断;注册完成后,将变化的提供方与当前已解析分节比较并严格校验。Settings 在更新、整体替换和路径修改的持久化之前调用此回调。外部重载使用相同的严格校验,失败时保留最后一次接受的分节。Settings 服务及其公共 API 保持不变。 + +首次解析 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。 + +Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。路由级校验中止目录解析时,不完整的目录及其已收集的逐模型诊断会被丢弃,该路由上的模型请求统一报告路由级错误。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。 + +`LlmConfigurableProvider.error` 优先为提供方行携带首个模型诊断,无模型诊断时返回路由错误。提供方构造失败不会覆盖已收集的模型诊断,从而保留缺少协议时的具体修复提示。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。 + +本决策扩展了[按提供方路由的适配器决策](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):提供方所有权与请求快照不变,目录有效性不决定设置是否可管理。旧记录仍保留为路由、所有权与回放设计的依据。 + +## Alternatives considered + +**在注册时拒绝目录错误。** 这会阻止用户修复结构可解析的配置,并让未使用的过期模型禁用无关提供方。 + +**同时放宽保存校验。** 对无法推断协议的新模型,可以立即拒绝并点名提供方与模型。接受它只会制造可避免的请求时错误。 + +**每次保存都严格重校验整个命名空间。** 无关提供方的旧错误会阻止添加正常提供方,或逐个修复提供方。 + +**为 OpenRouter 指定固定路由协议。** 路由覆盖会替换所有模型的协议,可能改变有意使用其他 API 的正常目录条目。 + +## Consequences + +依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。模型设置页不展示命名空间注册失败的原因,此类错误需要检查配置与启动诊断。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。 + +## Testing + +适配器测试覆盖有效与错误模型混合、覆盖目标被删除、独立提供方编辑、路由删除、联网前失败及修复。文件监听回归验证非法外部编辑保留最后一次接受的 profile,而修复后的文件能够生效。完整 Web 期望测试从过期 OpenRouter 设置启动,保留 zai 与两个添加控件,在不改变文件的前提下拒绝无效保存,并通过删除过期模型修复路由。已有快照测试继续负责请求冻结与回放行为。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.i18n.yaml new file mode 100644 index 0000000000..06d0950c8f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md +2026-09-07-typert-package-local-forwarding-imports.md: f50dc7bfc8c9d83c2b6f2b584e1d1119b8df817b +2026-09-07-typert-package-local-forwarding-imports.zh.md: 7e012d220df3e7356b35a105784f89e4df148802 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md new file mode 100644 index 0000000000..f50dc7bfc8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md @@ -0,0 +1,29 @@ +# Agent Note: Follow package-local forwarding modules in Typert references + +Status: implemented + +English | [中文](2026-09-07-typert-package-local-forwarding-imports.zh.md) + +## Problem + +`WorkspaceAnalyzer` resolves every type reference to its original declaration before classifying it, then reads only the referencing file's own `import` statement to decide whether the reference crossed a package through a public export. A package that re-exports another package's type from one of its own modules, and imports that module by relative path elsewhere, therefore fails with `crosses a package without an explicit package import` although the package import exists one hop away. The failure is deterministic for every batch size and package order; it surfaces in whichever analysis selects the referencing package as a root, which is why [issue 3525](https://github.com/deepseek-harness/deepseek-harness/issues/3525) observed it as batch-dependent. + +## Decision + +[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) resolves a relative specifier through the face's shared compiler host and module-resolution cache and follows it only while the resolved file stays inside the referencing package. In each forwarding module it collects the `export` edges that carry the requested name: a named re-export with a specifier, an `export { local }` backed by that module's `import`, and star re-exports whose module exports the same symbol. Explicit edges are tried before star edges, matching TypeScript's shadowing of star exports, and each resolved module and requested export-name pair is entered once, so circular star re-exports terminate while distinct renamed routes through one module remain available. The walk stops at the first package specifier and feeds that identity and export name to the existing `packageExportName` check, so a forwarded type must still be public at the package subpath the forwarding module names, and a package name without a registration is refused there. The reference model is unchanged: the target remains `declaration` for a same-face owner and `cross-face` for another face. + +The walk yields no package import, and the reference fails as before, when a relative specifier resolves outside the referencing package, when the only edge carrying the name is a namespace re-export or a re-exported namespace import, or when every edge loops back to a module and requested-name pair already entered. + +## Alternatives considered + +**Treat a relative import whose alias chain ends in another package as implicitly public.** Rejected: it would accept `../../other/src/file.ts` and any forwarding module that itself reaches the other package by relative path, removing the public-export check the generated Remote declarations rely on to name an importable subpath. + +**Record the forwarding module as the reference target.** Rejected: emitters and cross-face links need the original declaration's package and public subpath; a package-local module has no public identity of its own. + +**Select edges in source order without symbol checks.** Rejected: a star re-export that loops back to an earlier module can precede the explicit re-export that actually carries the type, and TypeScript itself lets explicit exports shadow star exports; ordering explicit edges first and continuing past an entered module and requested-name pair keeps such modules accepted without an unbounded walk. + +**Make batched and whole-workspace analysis select the same roots.** Rejected as a fix: root selection does not change the verdict on a reference, only whether the reference is visited, so aligning the callers would hide the incorrect classification rather than remove it. + +## Consequences + +Packages may keep one forwarding module for foreign types and import it relatively, matching how their own modules are organized. Each cross-package relative reference costs one module resolution per hop through the face's shared resolution cache; `reachableFiles` now resolves through the same cache. [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) pins named, renamed multi-hop, import-then-export, star, and namespace-import forwarding, an explicit re-export beside a looping star edge, distinct renamed routes through one shared module, a forwarded private export, a forwarding module that crosses by relative path, a cycle whose only exit crosses by relative path, a namespace re-export, a re-exported namespace import, cross-face forwarding, and equality of whole and batched analysis for the forwarding fixture across batch sizes and package orders. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.zh.md new file mode 100644 index 0000000000..7e012d220d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Typert 引用追踪包内转发模块 + +Status: implemented + +[English](2026-09-07-typert-package-local-forwarding-imports.md) | 中文 + +## Problem + +`WorkspaceAnalyzer` 先把每个类型引用解析到原始声明再分类,然后只读引用所在文件自己的 `import` 语句来判断该引用是否经由公开导出跨包。一个包若在自己的某个模块里重新导出另一个包的类型,并在别处用相对路径导入该模块,就会报 `crosses a package without an explicit package import`,尽管包导入只隔一跳。这个失败在任何批次大小和包顺序下都会稳定出现;它出现在哪次分析里,取决于哪次分析把引用方的包选为根,因此 [issue 3525](https://github.com/deepseek-harness/deepseek-harness/issues/3525) 观察到的现象像是与批次相关。 + +## Decision + +[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) 通过该 face 共享的编译器宿主及其模块解析缓存来解析相对说明符,且只在解析到的文件仍位于引用方包内时继续追踪。在每个转发模块里,它收集承载所请求名字的 `export` 边:带说明符的具名重新导出、由该模块自身 `import` 支撑的 `export { local }`,以及导出同一符号的星号重新导出。显式边先于星号边尝试,与 TypeScript 中显式导出遮蔽星号导出的规则一致;解析后的模块与请求导出名组成的每个组合只进入一次,因此循环的星号重新导出能够终止,经同一模块转发的不同改名路径仍可继续尝试。追踪在遇到第一个包说明符时停止,并把该包身份和导出名交给现有的 `packageExportName` 检查,因此被转发的类型仍必须在转发模块所写的包子路径上公开,没有登记的包名也在此被拒绝。引用模型不变:同 face 的所有者仍是 `declaration`,另一 face 仍是 `cross-face`。 + +当相对说明符解析到引用方包之外、承载该名字的唯一边是命名空间重新导出或被重新导出的命名空间导入,或所有边都回到已进入的模块与请求名组合时,追踪得不到包导入,引用照旧失败。 + +## Alternatives considered + +**把别名链终点在另一个包的相对导入视为隐式公开。** 已拒绝:这会接受 `../../other/src/file.ts`,也会接受自身用相对路径抵达另一个包的转发模块,从而取消公开导出检查,而生成的 Remote 声明依赖该检查来命名可导入的子路径。 + +**把转发模块记为引用目标。** 已拒绝:发射器和跨 face 链接需要原始声明的包和公开子路径,包内模块没有自己的公开身份。 + +**按源码顺序选边且不校验符号。** 已拒绝:回到更早模块的星号重新导出可能排在真正承载该类型的显式重新导出之前,而 TypeScript 本身允许显式导出遮蔽星号导出;显式边优先并跳过已进入的模块与请求名组合,既能接受这类模块,又不会无限追踪。 + +**让分批分析与全工作区分析选择相同的根。** 作为修复方案已拒绝:根的选择不改变对一个引用的判定,只决定该引用是否被访问,对齐调用方只会掩盖错误分类,不能消除它。 + +## Consequences + +包可以为外部类型保留一个转发模块并用相对路径导入它,与自身模块的组织方式一致。每个跨包相对引用每跳付出一次经该 face 共享解析缓存的模块解析;`reachableFiles` 现在也通过同一缓存解析。[`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 固定了具名、改名多跳、先导入再导出、星号和命名空间导入这几种转发,与回环星号边并存的显式重新导出,经同一模块转发的不同改名路径,被转发的私有导出,用相对路径跨包的转发模块,唯一出口用相对路径跨包的循环,命名空间重新导出,被重新导出的命名空间导入,跨 face 转发,以及转发 fixture 在不同批次大小和包顺序下全量分析与分批分析相等。 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index faa551cd78..32ebb78c30 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-ptc.md -2026-06-15-ptc.md: 96dda525c677af638654cef042b583803d948707 -2026-06-15-ptc.zh.md: 1da45205c62eb054fd534e4f395e570244066c5a +2026-06-15-ptc.md: 43bd4a4fd5c49a449ceccb7f1889b80b0844214d +2026-06-15-ptc.zh.md: a6aaf203186ad3023ce39a9df04fc1b233db88eb diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index 96dda525c6..43bd4a4fd5 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -42,7 +42,7 @@ This note owns PTC mode's presentation, composition, isolation, and settlement f Under `'ptc'` and `'both'` the registry owns `run_code` as a reserved presentation transport with two required parameters, `{ code: string; description: string }` (the description labels the call in UIs, the bash precedent). It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove PTC mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → optional definition-owned `finalizeContent` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, enters the native-contract dispatch pool (the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduling design), executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs the `tool/code-dispatch-start`/`tool/code-dispatch` pair, the settle side carrying the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. +1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, enters the native-contract dispatch pool (the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduling design), executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs the `tool/ptc-dispatch-start`/`tool/ptc-dispatch` pair, the settle side carrying the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. 3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured logs and the completion value as canonical output; the registry renders that value into durable `tool/result.content`, which the result card reads directly. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. @@ -52,9 +52,13 @@ Under `'ptc'` and `'both'` the registry owns `run_code` as a reserved presentati **Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so the TUI and host/client runtime (Web) complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md). -### Observability: `tool/code-dispatch` +### Observability: `tool/ptc-dispatch` -Each sub-dispatch appends a log-only `tool/code-dispatch-start` event at pool entry and a `tool/code-dispatch` settle event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. +Each sub-dispatch appends a log-only `tool/ptc-dispatch-start` event at pool entry and a `tool/ptc-dispatch` settle event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. + +New sub-calls use `:ptc:` ids, numbered in submission order. All call ids are opaque to consumers: migration preserves every historical id byte-for-byte, including `:code:` substrings, so dispatch pairs, spill references, and other correlations remain intact. The bridge attributes forwarded image context to `{ kind: 'plugin', plugin: 'tools-ptc' }`. + +The [V2-to-V3 PTC specification](../../../../packages/session/session-format-v2-to-v3/README.md#ptc-vocabulary) owns exact historical tag and attribution conversion; [native V3 admission](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission) owns predecessor-tag refusal. These are not runtime aliases: an opaque extension must not acquire PTC lifecycle meaning merely through a version change. ### The code-runtime seam @@ -98,7 +102,7 @@ Deployments switching to `'ptc'` must update any native-only `toolOrder`. Assemb - **Worker runtime:** Real-worker tests cover typed binding values and failures, every lossless JSON completion root, invalid and over-limit output, exact combined ledger boundaries, compute and wall budgets, hostile binding traffic, empty environment, and disposal to quiescence. A built-package test runs the worker entry under plain Node. - **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, ordered context deferral across successful and failed programs, outer-block suppression, and HMR cleanup. - **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a PTC mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. -- **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. +- **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. The TypeScript SDK PTC scenario mounts its worker runtime through an explicit test-owned profile patch and pins Session events and JSON-RPC notifications; its expected response and completed-turn checks run before refresh writes. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 1da45205c6..a6aaf20318 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -42,7 +42,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 在 `'ptc'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带两个必需参数 `{ code: string; description: string }`(description 为 UI 标注该调用,沿用 bash 的先例)。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 PTC mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调性守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: -1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生约定的分发池(调度设计由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch-start`/`tool/code-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 +1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生约定的分发池(调度设计由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/ptc-dispatch-start`/`tool/ptc-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 3. **完全停稳后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值,将其作为规范输出;注册表再把该值渲染为持久化的 `tool/result.content`,供结果卡片直接读取。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 @@ -52,9 +52,13 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 **呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.zh.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput`;`run_code` 有意不声明 `presentResult`,因此 TUI 和宿主/客户端运行时(Web)会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy spill 预览。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md)。 -### 可观测性:`tool/code-dispatch` +### 可观测性:`tool/ptc-dispatch` -每次子分发在进入分发池时追加一个仅日志的 `tool/code-dispatch-start` 事件,并以一个 `tool/code-dispatch` 结算事件收尾,后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。 +每次子分发在进入分发池时追加一个仅日志的 `tool/ptc-dispatch-start` 事件,并以一个 `tool/ptc-dispatch` 结算事件收尾,后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。 + +新子调用使用 `:ptc:` 标识,按提交顺序编号。消费者将所有 call id 视为不透明值:迁移逐字节保留每个历史标识,包括 `:code:` 子串,因此分发事件对、spill 引用及其他关联保持完整。桥接层将转发图片上下文的来源标记为 `{ kind: 'plugin', plugin: 'tools-ptc' }`。 + +[V2 到 V3 PTC 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#ptc-vocabulary)负责精确的历史标签与归属转换;[原生 V3 准入](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)负责前代标签拒绝。这些不是运行时别名:不透明扩展不能仅因版本变化就获得 PTC 生命周期含义。 ### code-runtime seam @@ -98,7 +102,7 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 - **Worker 运行时:** 真实 worker 测试覆盖类型化的绑定值与失败、每一种无损 JSON 根类型的完成值、无效和超限输出、精确的组合账本边界、compute 和 wall 预算、恶意绑定流量、空环境以及 dispose 至完全停稳。一个构建后包测试在纯 Node 下运行 worker 入口。 - **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层阻止抑制以及 HMR(热模块替换)清理。 - **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 PTC mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 -- **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。 +- **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。TypeScript SDK PTC 场景通过测试拥有的显式 profile patch 挂载 worker 运行时,并固定 Session 事件与 JSON-RPC 通知;预期回复和完成轮次检查在 refresh 写入前执行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index e7caf58a0e..4e2bd8d28d 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 770960498b0d873009ff2a5fc63af59c21259398 -2026-06-18-compaction-capability-seam.zh.md: a05df813cb573ee7adea60723063719f1d104cd9 +2026-06-18-compaction-capability-seam.md: 7d8b4f5011385f306aec4c6374d3402427fb3cc4 +2026-06-18-compaction-capability-seam.zh.md: 6a255bb05e0b3c90077af051bd24744305f3833e diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 770960498b..7d8b4f5011 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -8,7 +8,7 @@ English | [中文](2026-06-18-compaction-capability-seam.zh.md) A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. -The [session surface](../architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — an ordered projection over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of entries and insert a replacement, with `sourceEventSeqs` listing every source event so replay can validate that the replacement cites every event it removes. What remained was the plugin that *decides what to compact and produces the summary*. +The [session surface](../architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — an ordered projection over the event log with a `surfaceOp: { op: 'replace', startSeq, endSeq }` operation purpose-built to shadow a range of entries and insert a replacement, with `sourceEventSeqs` listing every source event so replay can validate that the replacement cites every event it removes. What remained was the plugin that *decides what to compact and produces the summary*. Two forces shape the design. First, compaction policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../../archived/architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. Second, `SurfaceEventType` is closed to the message-producing event types (`user/message`, `assistant/message`, `tool/result`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler and Session's always-on append/seed boundary reject `surfaceOp` on it. @@ -71,13 +71,13 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Surface replacement: `compaction/*` events are log-only; one `user/message` carries the summary -Because `SurfaceEventType` is closed, the summary cannot ride on a `compaction/*` event. The backend instead appends a **single `user/message`** with `source: COMPACT_CHECKPOINT_SOURCE` and `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed entries *and* the bookkeeping events. The interface exports that source and `isCompactCheckpointSource()` so consumers recognize a persisted or cloned checkpoint without depending on backend package identity. The `compaction/*` events record the lock, summary, selected range, shadowed seqs, token count, and model call without joining the surface. The surface mutation sits **inside** the lock — `compaction/end` is the last event appended: +Because `SurfaceEventType` is closed, the summary cannot ride on a `compaction/*` event. The backend instead appends a **single `user/message`** with `source: COMPACT_CHECKPOINT_SOURCE` and `surfaceOp: { op: 'replace', startSeq, endSeq }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed entries *and* the bookkeeping events. The interface exports that source and `isCompactCheckpointSource()` so consumers recognize a persisted or cloned checkpoint without depending on backend package identity. The `compaction/*` events record the lock, summary, selected range, shadowed seqs, token count, and model call without joining the surface. The surface mutation sits **inside** the lock — `compaction/end` is the last event appended: ``` compaction/start → log-only. Acquires the lock. [summarize older range via the backend] compaction/summary → log-only. Records the raw summary, local-call marker, range, shadowed seqs, and token count. -user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }. +user/message → canonical checkpoint source + surfaceOp { op:'replace', startSeq, endSeq }. THE surface mutation (framed summary). deriveMessages() renders it as a user-role message. compaction/end → log-only. Releases the lock (carries `error` on a recoverable failure). diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index a05df813cb..6a255bb05e 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -8,7 +8,7 @@ Status: implemented 长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口,模型随即在响应中途停止生成(`max-tokens`),或表现退化。**上下文压缩(context compaction)** 是对此的缓解手段:用一段简洁的摘要替换一批较早的历史,保持近期上下文完整。 -[会话接口面](../architecture/2026-06-18-session-surface.zh.md)正是为此而构建的基础设施:一份建立在事件日志之上的有序投影,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段条目并插入替换内容,`sourceEventSeqs` 列出每个来源事件,使回放可以验证替换是否引用了它移除的每个事件。剩下的是那个*决定压缩什么、并产出摘要*的插件。 +[会话接口面](../architecture/2026-06-18-session-surface.zh.md)正是为此而构建的基础设施:一份建立在事件日志之上的有序投影,带有专门设计的 `surfaceOp: { op: 'replace', startSeq, endSeq }` 操作,用于遮蔽一段条目并插入替换内容,`sourceEventSeqs` 列出每个来源事件,使回放可以验证替换是否引用了它移除的每个事件。剩下的是那个*决定压缩什么、并产出摘要*的插件。 两股力量塑造了设计。第一,压缩策略与可复用的 token 测量独立变化:测量归 LLM(大语言模型)系列的 [`ctx.tokenMeter` 服务](../../archived/architecture/2026-07-15-replay-token-meter-service.md)所有,摘要生成则可以使用模型调用、模板或远程服务。第二,`SurfaceEventType` 封闭为产生消息的事件类型(`user/message`、`assistant/message`、`tool/result`);只有这些类型可以携带 `surfaceOp`。因此一个专用的 `compaction/*` 事件**不能**出现在 surface 上,编译器与 Session 始终启用的 append/seed 边界都会拒绝在其上附加 `surfaceOp`。 @@ -71,13 +71,13 @@ retry → next numbered step/start ⟵ derives from the replacement surface ### Surface 替换:`compaction/*` 事件仅存在于日志;一条 `user/message` 承载摘要 -由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compaction/*` 事件上。后端改为追加**单条 `user/message`**,带有 `source: COMPACT_CHECKPOINT_SOURCE` 和 `surfaceOp: { op: 'replace', start, end }`;其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的条目*和*簿记事件。接口导出该来源和 `isCompactCheckpointSource()`,使消费方无需依赖后端包身份,即可识别持久化或克隆得到的检查点。`compaction/*` 事件记录锁、摘要、选中区间、被遮蔽的 seq、token 数和模型调用,但不加入 surface。surface 变更位于锁**内部**,`compaction/end` 是最后追加的事件: +由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compaction/*` 事件上。后端改为追加**单条 `user/message`**,带有 `source: COMPACT_CHECKPOINT_SOURCE` 和 `surfaceOp: { op: 'replace', startSeq, endSeq }`;其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的条目*和*簿记事件。接口导出该来源和 `isCompactCheckpointSource()`,使消费方无需依赖后端包身份,即可识别持久化或克隆得到的检查点。`compaction/*` 事件记录锁、摘要、选中区间、被遮蔽的 seq、token 数和模型调用,但不加入 surface。surface 变更位于锁**内部**,`compaction/end` 是最后追加的事件: ``` compaction/start → log-only. Acquires the lock. [summarize older range via the backend] compaction/summary → log-only. Records the raw summary, local-call marker, range, shadowed seqs, and token count. -user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }. +user/message → canonical checkpoint source + surfaceOp { op:'replace', startSeq, endSeq }. THE surface mutation (framed summary). deriveMessages() renders it as a user-role message. compaction/end → log-only. Releases the lock (carries `error` on a recoverable failure). diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 5db7ce1ba0..f39eb5ce66 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: a6e5639e21ca7140cb0314c9918319e99b1495f6 -2026-07-06-sandbox.zh.md: 5144b3fa719465707d7fb2870d45094b8c07661a +2026-07-06-sandbox.md: 31d96836ad2f932f2abf7d1d76242a711f0de2f6 +2026-07-06-sandbox.zh.md: fdf5f4b1691b4a55fffbd206847c99307e12c9da diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index a6e5639e21..31d96836ad 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -64,7 +64,7 @@ Left open, for the phase that needs them: whether network restriction arrives as The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; every launcher failure exits 125 without running the child and prints a fatal `landlock-run:` line. A successfully exec'd child may also return 125, so status alone is not launcher evidence. An older ABI prints the exact `landlock-run: partial enforcement (older Landlock ABI)` notice before it executes the child, so that line is not fatal evidence. -The Landlock launcher source and package family live at `native/landlock-run`, next to the harness consumers and inside the root pnpm workspace. The [`native/` README](../../../../native/README.md) owns the shared lockfile, native build, pack rehearsal, and npm publication boundary. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. +The Landlock launcher source and package family live at `native/system`, next to the harness consumers and inside the root pnpm workspace. The [`native/` README](../../../../native/README.md) owns the shared lockfile, native build, pack rehearsal, and npm publication boundary. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 5144b3fa71..fdf5f4b169 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -64,7 +64,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 约定):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它为自身安装规则集并执行 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 -Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 消费方同仓,并属于根 pnpm workspace。[`native/` README](../../../../native/README.zh.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI(命令行界面)参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 +Landlock launcher 源码和包家族位于 `native/system`,与 harness 消费方同仓,并属于根 pnpm workspace。[`native/` README](../../../../native/README.zh.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI(命令行界面)参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 后端 profile 共享模式约定但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。 diff --git a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml index 7de92a4dd5..be9a598897 100644 --- a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md -2026-07-20-ptc-typed-tool-returns.md: 04ef9f7da4cd59ba07632684541dd6962008b810 -2026-07-20-ptc-typed-tool-returns.zh.md: c197d3131cc0f7fa326a9a47d945b2b7730f01c0 +2026-07-20-ptc-typed-tool-returns.md: b7d7cc56210b229d7e36f8face888a34c9d1ac3e +2026-07-20-ptc-typed-tool-returns.zh.md: 20e14cdac6151ceead084ff6a78eb5f7a6277af1 diff --git a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md index 04ef9f7da4..b7d7cc5621 100644 --- a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md @@ -73,7 +73,7 @@ Temporary Cordis Plugins follow the same rule: `cordis_mount` returns `{ id, plu ### Persistence, metadata, and spill -Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/code-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. This feature did not itself require a structural Session-format change; the released v0-to-v1 identity edge preserves these records, and replay still cannot recreate intermediate canonical program values. +Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/ptc-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. The [PTC mode note](2026-06-15-ptc.md) owns durable event names and historical identity preservation; replay cannot recreate intermediate canonical program values. The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls; their canonical values never enter context. The Client can derive [nested terminal cards](../bug-fix/2026-09-05-nested-terminal-cards.md) from raw dispatch events without metadata. The outer `run_code` call produces the model-facing result and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so UI adapters complete the card through their generic raw-content fallback using durable `tool/result.content`. diff --git a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md index c197d3131c..20e14cdac6 100644 --- a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md @@ -73,7 +73,7 @@ PTC mode 通过运行时请求中的 `{ name: "ToolCallError", memberNamePropert ### 持久化、元数据与 spill -嵌套分发在 `tool/code-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。该功能本身不要求结构性 Session 格式变更;已发布的 v0-to-v1 恒等边会保留这些记录,回放仍无法重建程序的规范中间值。 +嵌套分发在 `tool/ptc-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。[PTC mode note](2026-06-15-ptc.zh.md) 负责持久事件名称与历史标识保留规则;回放无法重建程序的规范中间值。 不透明的 `exec.parent` token 用于标识嵌套调用。展示元数据以及通用或工具自有的 spill 投影都会跳过这些调用;它们的规范值永远不会进入上下文。Client 可以从原始分发事件派生[嵌套 terminal 卡片](../bug-fix/2026-09-05-nested-terminal-cards.zh.md),无需元数据。外层 `run_code` 调用产生面向模型的结果,并且可能对 post-policy 处理后的最终展示执行 spill;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 UI 适配器会通过通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml index fe3522ee91..ae720a62ac 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md -2026-07-26-ptc-live-parallel-dispatch.md: 53388e4b4cc067527bd1ed04d6c66054e84ecd44 -2026-07-26-ptc-live-parallel-dispatch.zh.md: 8eb8e6d743be65db50a4685eafb2496bc791141c +2026-07-26-ptc-live-parallel-dispatch.md: f064314098af9c074f7cd06dc9ceea3df4c91c76 +2026-07-26-ptc-live-parallel-dispatch.zh.md: 70cbebcdc30c5771a2c29f36b588232822e19269 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md index 53388e4b4c..f064314098 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-26-ptc-live-parallel-dispatch.zh.md) -> Scope: the `tool/code-dispatch-start` event, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the [host foundation](../../archived/feature/2026-07-26-ptc-dispatch-ui-foundation.md) and [chat sub-call rows](../../archived/feature/2026-07-26-ptc-chat-subcall-rows.md); the native contract itself is owned by the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md). +> Scope: the `tool/ptc-dispatch-start` event, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the [host foundation](../../archived/feature/2026-07-26-ptc-dispatch-ui-foundation.md) and [chat sub-call rows](../../archived/feature/2026-07-26-ptc-chat-subcall-rows.md); the native contract itself is owned by the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md). ## Problem @@ -14,7 +14,7 @@ Two gaps remained after the host foundation and chat sub-call rows shipped. Sub- **One lifecycle pair, one scheduling contract, shared with native.** -- **Event pair**: `tool/code-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/code-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched; format stays v0. +- **Event pair**: `tool/ptc-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/ptc-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched. - **Bridge scheduler**: submitted calls are classified at start time via `registry.executionMode` (the SAME fail-closed `isConcurrencySafe` contract the loop uses) and start strictly in submission order. One single-lane driver owns every ORDERED stage — the start append, `prepare` (pre-execute/guards), the head-of-line `finalize`/`finish` commit (post-execute + context deferral + settle append) — so ordered policy stages never overlap each other and only the around-dispatch/body stage runs concurrently, exactly the native loop's sequencing (`fillPool` awaits `startCall` then `commitReady`). Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (a `Config` field validated by the Loader schema AND re-validated at direct construction, default 10 — the loop scheduler's own default; `1` restores serial dispatch); an exclusive call drains the pool, runs alone, and holds its barrier until its COMMIT completes (post-execute included), like a native exclusive group. Run settlement aborts in-flight dispatches and abandons queued-unstarted ones (binding rejection, no events), then drains to quiescence — including a commit already mid-flight when the program returned — before the outer result closes the turn. - **Client**: Runtime's `ToolCallTree` stores a start event as a `RunningToolCall` child and projects it through the parent's recursive `subCalls` (rows derive the running ring from that shape, exactly as for native in-flight calls). Its settle replaces the private-index entry in place, preserving start order under parallel completion and carrying the start's `time` as `callTime` (duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering. - **SDK prompt**: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under `Promise.all`; dependent work sequences with `await`) — a model-visible change, re-recorded across every ptc snapshot. diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md index 8eb8e6d743..70cbebcdc3 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-26-ptc-live-parallel-dispatch.md) | 中文 -> 范围:`tool/code-dispatch-start` 事件、Web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发约定的复用。构建在[宿主侧基础](../../archived/feature/2026-07-26-ptc-dispatch-ui-foundation.md)与 [chat 子调用行](../../archived/feature/2026-07-26-ptc-chat-subcall-rows.md)之上;原生约定本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.zh.md) 所有。 +> 范围:`tool/ptc-dispatch-start` 事件、Web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发约定的复用。构建在[宿主侧基础](../../archived/feature/2026-07-26-ptc-dispatch-ui-foundation.md)与 [chat 子调用行](../../archived/feature/2026-07-26-ptc-chat-subcall-rows.md)之上;原生约定本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.zh.md) 所有。 ## 问题 @@ -14,7 +14,7 @@ Status: implemented **一对生命周期事件,一份调度约定,与原生共用。** -- **事件对**:`tool/code-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/code-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件仍仅用于日志;模型上下文不受影响;格式保持 v0。 +- **事件对**:`tool/ptc-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/ptc-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件仍仅用于日志;模型上下文不受影响。 - **桥接层调度器**:已提交的调用在启动那一刻经 `registry.executionMode` 分类(与 loop 所用完全相同、故障时默认判为不安全的 `isConcurrencySafe` 约定),并严格按提交顺序启动。所有有序阶段——start 事件追加、`prepare`(pre-execute/守卫)、队首 `finalize`/`finish` 提交(post-execute + 上下文延迟提交 + settle 事件追加)——由单通道驱动器独占执行,因此有序策略阶段彼此绝不重叠,只有 around-dispatch/工具体阶段并发运行,与原生 loop 的时序完全一致(`fillPool` 先 await `startCall` 再 `commitReady`)。连续被分类为可并行的调用可以重叠执行,上限为 `maxParallelSubCalls`(`Config` 字段,Loader schema 校验之外直接构造时也重新校验,默认值 10,即 loop 调度器自身的默认值;设为 `1` 即恢复串行分发);独占调用则先排空池、独自运行,且其屏障保持到自身提交(含 post-execute)完成为止,与原生独占分组一致。run 结算时会中止仍在运行的分发,并放弃已排队未启动的分发(绑定调用被拒绝,不产生事件),随后排空到完全停稳——包括程序返回时已在途的提交——之后外层结果才结束该轮次。 - **客户端侧**:运行时的 `ToolCallTree` 把 start 事件存为 `RunningToolCall` 子级,并通过父级递归的 `subCalls` 投影出来(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致)。其结算事件会原位替换私有索引中的条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。 - **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实约定(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 PTC mode 快照都已重新录制。 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 55a907edf1..97110cbb0c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: df334bc0bd9d011ea2e2f4ee938fb29c4c4bfe2c -2026-07-31-even-out-shipped-tool-rosters.zh.md: ddee380f836bf50a733541b2d3ea6dd8a74126a2 +2026-07-31-even-out-shipped-tool-rosters.md: caadb4601a56e9180ccb8d1eae571208941cbcba +2026-07-31-even-out-shipped-tool-rosters.zh.md: 9503baf9f1f8f2567c3ccd81db685c74db189430 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index df334bc0bd..caadb4601a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,7 +12,7 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-reminder`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md). Two later decisions narrow that roster: the [session-search decision](../../archived/feature/2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, and the [single-editor decision](../../archived/simplification/2026-08-10-default-presets-single-editor.md) keeps `tool-str-replace-editor` out of the general-purpose presets while retaining it in `minimal`. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-reminder`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md). Later decisions narrow that roster: the [session-search decision](../../archived/feature/2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, the [single-editor decision](../../archived/simplification/2026-08-10-default-presets-single-editor.md) removes `tool-str-replace-editor` from general-purpose presets, and the [persistent-shell-only decision](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md) removes it from the minimal compositions. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index ddee380f83..9503baf9f1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-reminder`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.zh.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md)。之后有两项决策收窄这份清单:[session-search 决策](../../archived/feature/2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一编辑器决策](../../archived/simplification/2026-08-10-default-presets-single-editor.md)让通用 preset 不提供 `tool-str-replace-editor`,但在 `minimal` 中保留它。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-reminder`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.zh.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md)。后续决策收窄了这份清单:[session-search 决策](../../archived/feature/2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一 editor 决策](../../archived/simplification/2026-08-10-default-presets-single-editor.md)从通用 preset 移除 `tool-str-replace-editor`,[仅持久 shell 决策](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)则从极简组合移除它。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 21322238d5..d95f30747e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 52665a44b654a50b8dc28f4bbd530a0606f8e2d9 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f16d2412826db5d0fd3dcaad1281ccd92acdd26e +2026-08-04-claude-code-and-codex-subagent-backends.md: d11b200e95cbb0769ad30ae88cb976c7921c80f8 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 5899a27bb8ad592d9b83b7d19e681bef375525fb diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 52665a44b6..d11b200e95 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -34,21 +34,21 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro ## Codex provider -`@deepseek-ai/dsh-subagent-codex` registers a Profile-selected provider name that defaults to `codex`, resolves the `codex` bin declared by its pinned `@openai/codex@0.149.1` package, and starts that wrapper through the current Node executable with `app-server --stdio`. The wrapper selects the private native platform payload; the provider neither resolves nor falls back to a host `codex`. Its public configuration contains a non-empty `providerName`, an optional non-empty `model`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Each named instance retains those resolved values for its own runs. An explicit model is passed unchanged on every ephemeral `thread/start`; omission leaves native Codex settings authoritative. Installation, login, `CODEX_HOME`, model discovery or fallback, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision. +`@deepseek-ai/dsh-subagent-codex` registers a Profile-selected provider name that defaults to `codex`, resolves the `codex` bin declared by its pinned `@openai/codex@0.153.4` package, and starts that wrapper through the current Node executable with `app-server --stdio`. The wrapper selects the private native platform payload; the provider neither resolves nor falls back to a host `codex`. Its public configuration contains a non-empty `providerName`, an optional non-empty `model`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Each named instance retains those resolved values for its own runs. An explicit model is passed unchanged on every ephemeral `thread/start`; omission leaves native Codex settings authoritative. Installation, login, `CODEX_HOME`, model discovery or fallback, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision. Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the optional model and resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no model, mode, or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. `turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The [minimal-diagnostics decision](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns Codex action categories, HTTP status, lifecycle stages, process outcomes, and stop-reason preservation. Local cancellation remains `aborted` without a failure diagnostic. -For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.149.1 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and structured `sandboxError` terminals. Product stderr is forwarded unchanged to the Host but is neither classified nor copied into the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. +For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; a request without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and structured `sandboxError` terminals. Product stderr is forwarded unchanged to the Host but is neither classified nor copied into the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal. -Codex 0.149.1 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. +Codex 0.153.4 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers a Profile-selected provider name that defaults to `claude-code` and invokes `@anthropic-ai/claude-agent-sdk@0.3.241`. The provider omits `pathToClaudeCodeExecutable`, so the SDK selects Claude Code 2.1.241 from the matching OS, CPU, and Linux-libc platform package in its own optional dependency closure. The provider does not resolve or fall back to a host `claude`; an omitted, unsupported, missing, or damaged platform payload fails the first delegation at the SDK startup boundary. The provider uses the official `query()` entrypoint and passes the SDK's native `claude` or `claude.exe` command, arguments, cwd, environment, and forwarded signal from `spawnClaudeCodeProcess` to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers a Profile-selected provider name that defaults to `claude-code` and invokes `@anthropic-ai/claude-agent-sdk@0.3.263`. The provider omits `pathToClaudeCodeExecutable`, so the SDK selects Claude Code 2.1.263 from the matching OS, CPU, and Linux-libc platform package in its own optional dependency closure. The provider does not resolve or fall back to a host `claude`; an omitted, unsupported, missing, or damaged platform payload fails the first delegation at the SDK startup boundary. The provider uses the official `query()` entrypoint and passes the SDK's native `claude` or `claude.exe` command, arguments, cwd, environment, and forwarded signal from `spawnClaudeCodeProcess` to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains a non-empty `providerName`, an optional non-empty `model`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each named instance retains those resolved values for its own runs. An explicit model is passed unchanged through `Options.model`; omission leaves that field absent so native settings choose the model. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. @@ -62,11 +62,11 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.149.1`, `codex-cli 0.149.1`, and all six optional platform aliases. Its generated schema proves optional `ThreadStartParams.model`; the real-product spec observes omitted-model inheritance, two explicit instance models, the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, named instances retain separate models, environments, and modes, and production never resolves a host `codex` from `PATH`. The [minimal-diagnostics decision](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns failure, process-outcome, and final presentation evidence. +The Codex evidence pins `@openai/codex@0.153.4`, `codex-cli 0.153.4`, and all six optional platform aliases. Its generated schema proves optional `ThreadStartParams.model`; the real-product spec observes omitted-model inheritance, two explicit instance models, the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, named instances retain separate models, environments, and modes, and production never resolves a host `codex` from `PATH`. The [minimal-diagnostics decision](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns failure, process-outcome, and final presentation evidence. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.241, Claude Code 2.1.241, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes omitted-model inheritance, two explicit instance models, the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [minimal-diagnostics decision](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. +The Claude Code evidence pins Agent SDK 0.3.263, Claude Code 2.1.263, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes omitted-model inheritance, two explicit instance models, the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [minimal-diagnostics decision](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index f16d241282..5899a27bb8 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -34,21 +34,21 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro ## Codex 提供方 -`@deepseek-ai/dsh-subagent-codex` 注册由 Profile 选择、默认值为 `codex` 的提供方名称,解析锁定的 `@openai/codex@0.149.1` 包所声明的 `codex` bin,并使用当前 Node 可执行文件加 `app-server --stdio` 启动该 wrapper。Wrapper 会选择私有原生平台载荷;提供方既不解析也不回退宿主 `codex`。其公开配置包含非空的 `providerName`、可选的非空 `model`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。显式模型会原样传给每个临时 `thread/start`;省略时仍以 Codex 原生设置为权威。安装、登录、`CODEX_HOME`、模型发现或 fallback、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。 +`@deepseek-ai/dsh-subagent-codex` 注册由 Profile 选择、默认值为 `codex` 的提供方名称,解析锁定的 `@openai/codex@0.153.4` 包所声明的 `codex` bin,并使用当前 Node 可执行文件加 `app-server --stdio` 启动该 wrapper。Wrapper 会选择私有原生平台载荷;提供方既不解析也不回退宿主 `codex`。其公开配置包含非空的 `providerName`、可选的非空 `model`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。显式模型会原样传给每个临时 `thread/start`;省略时仍以 Codex 原生设置为权威。安装、登录、`CODEX_HOME`、模型发现或 fallback、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把可选模型与已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模型、模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 `turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。[最小诊断决策](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md)负责 Codex 行动类别、HTTP status、生命周期阶段、进程结果与终止原因保持。本地取消仍是 `aborted` 且不附带失败诊断。 -对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.149.1 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与结构化 `sandboxError` 终态的安全类别。产品 stderr 会原样转发给 Host,但既不会被分类,也不会复制进诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 +对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;若请求没有决策选项列表,则回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与结构化 `sandboxError` 终态的安全类别。产品 stderr 会原样转发给 Host,但既不会被分类,也不会复制进诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。 -Codex 0.149.1 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 +Codex 0.153.4 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册由 Profile 选择、默认值为 `claude-code` 的提供方名称,并调用 `@anthropic-ai/claude-agent-sdk@0.3.241`。提供方会省略 `pathToClaudeCodeExecutable`,因此 SDK 会从自己的 optional dependency 闭包中,按操作系统、CPU 与 Linux libc 选择携带 Claude Code 2.1.241 的匹配平台包。提供方既不会解析也不会回退宿主 `claude`;省略 optional dependency、不受支持的平台,以及缺失或损坏的平台载荷,都会在第一次委派的 SDK 启动边界失败。提供方使用官方 `query()` 入口点,并把 SDK 的 `spawnClaudeCodeProcess` 给出的原生 `claude` 或 `claude.exe` 命令、参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册由 Profile 选择、默认值为 `claude-code` 的提供方名称,并调用 `@anthropic-ai/claude-agent-sdk@0.3.263`。提供方会省略 `pathToClaudeCodeExecutable`,因此 SDK 会从自己的 optional dependency 闭包中,按操作系统、CPU 与 Linux libc 选择携带 Claude Code 2.1.263 的匹配平台包。提供方既不会解析也不会回退宿主 `claude`;省略 optional dependency、不受支持的平台,以及缺失或损坏的平台载荷,都会在第一次委派的 SDK 启动边界失败。提供方使用官方 `query()` 入口点,并把 SDK 的 `spawnClaudeCodeProcess` 给出的原生 `claude` 或 `claude.exe` 命令、参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含非空的 `providerName`、可选的非空 `model`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。显式模型会原样传入 `Options.model`;省略时不设置该字段,由原生设置选择模型。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 @@ -62,11 +62,11 @@ Codex 0.149.1 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据会锁定 `@openai/codex@0.149.1`、`codex-cli 0.149.1` 与六个平台 alias。生成 schema 会证明可选的 `ThreadStartParams.model`;真实产品测试会观测省略模型继承、两个显式实例模型、包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,命名实例会保留彼此独立的模型、环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[最小诊断决策](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md)负责失败、进程结果与最终呈现证据。 +Codex 证据会锁定 `@openai/codex@0.153.4`、`codex-cli 0.153.4` 与六个平台 alias。生成 schema 会证明可选的 `ThreadStartParams.model`;真实产品测试会观测省略模型继承、两个显式实例模型、包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,命名实例会保留彼此独立的模型、环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[最小诊断决策](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md)负责失败、进程结果与最终呈现证据。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据会锁定 Agent SDK 0.3.241、Claude Code 2.1.241 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测省略模型继承、两个显式实例模型、确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[最小诊断决策](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 +Claude Code 证据会锁定 Agent SDK 0.3.263、Claude Code 2.1.263 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测省略模型继承、两个显式实例模型、确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[最小诊断决策](../../archived/simplification/2026-08-21-product-subagent-minimal-diagnostics.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index 5a8b133ed6..98786c2a8b 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 9e4d476c36ab6f920481b1281058d888710a2ef5 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 761c384f1656ce66d027eb2b13c9dbc4f20f3686 +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 8cfa4aa71e33317947d0661641df1e6e814f5e90 +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: f1f916bdde92fd104c3d879ff058b2e0474eef38 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index 9e4d476c36..8cfa4aa71e 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -1,4 +1,4 @@ -# Agent Note: Minimal profiles use the bare two-tool runtime +# Agent Note: Minimal profiles use a bare runtime Status: implemented @@ -12,23 +12,23 @@ The two launch paths also have different configuration owners. Web mounts a per- ## Decision -The shipped Web minimal preset exposes persistent `bash` and `str_replace_editor`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows, plus the same editor. Both mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. +The shipped Web minimal preset exposes persistent `bash`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows. Both mount no context-compaction or filesystem provider and suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions. The [persistent-shell-only decision](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md) removes the editor and its otherwise-unused `fs-local` provider from both compositions. The Web preset's persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. -The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, `fs-local`, that shell's tool consumer, the editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. The persistent shell consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. [docs/architecture.md](../../../../docs/architecture.md) owns this bundle placement and its separation from `dsh-base`. +The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, that shell's tool consumer, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-local`, `fs-sandbox`, `fs-observation-policy`, or a filesystem tool. The persistent shell consumes the profile's danger-full-access sandbox policy. [docs/architecture.md](../../../../docs/architecture.md) owns this bundle placement and its separation from `dsh-base`. `DSH_SYSTEM_PROMPT` selects the standalone persona, and `DSH_CONTEXT_WINDOW` supplies fallback capacity for a model without exact catalog metadata. The SDK client's JSON-RPC `initialize` request is the sole runtime model selection. [`minimal.py`](../../../../python/sdk/examples/minimal.py) may read `DSH_MODEL` only as the command's default `model` argument; an explicit `--model` needs no matching child environment value. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. ## Verification -The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services. +The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that no scoped filesystem or compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and persistent Bash. It then executes persistent Bash against the real scoped services. -The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. +The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact single-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. ## Alternatives considered **Keep `compaction-basic` mounted with a high threshold.** Rejected because even an inert-for-short-tests provider permits history replacement in longer sessions and leaves the minimal composition dependent on model-capacity metadata and the token meter. -**Keep `fs-sandbox` in danger-full-access mode.** Rejected because the sandboxed provider still makes confinement and escalation part of the editor capability. The target runtime requires the bare local provider, whose lack of `sandboxMode` is composition truth. +**Keep `fs-local` after removing the editor.** Rejected because neither minimal composition has another filesystem consumer. Retaining the provider would increase the runtime roster without adding a model-visible capability. **Use one Cordis leaf for Web and Python SDK startup.** Rejected because a Web preset contributes agent-scoped services to an existing multi-session host, while the Python SDK must launch a complete process containing the JSON-RPC server and its process-wide dependencies. @@ -36,4 +36,4 @@ The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a ## Consequences -Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The editor can address any absolute path visible to the runtime process, independently of the persistent shell's sandbox policy. The two launch paths share their model-facing tool, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile. +Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The two launch paths share their single-tool, no-filesystem, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile. diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index 761c384f16..f1f916bdde 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -1,4 +1,4 @@ -# Agent Note: minimal profile 使用裸双工具运行时 +# Agent Note: minimal profile 使用裸运行时 Status: implemented @@ -12,23 +12,23 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` ## 决策 -随附 Web minimal preset 对外提供持久 `bash` 与 `str_replace_editor`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`,并提供相同 editor。两者都不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 +随附 Web minimal preset 对外提供持久 `bash`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`。两者都不挂载上下文压缩或文件系统提供方,并为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献。[仅持久 shell 决策](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)从两份组合中移除了编辑器及其原本除此之外无人使用的 `fs-local` 提供方。Web preset 的 persona 继续采用较早的 [minimal preset 组合决策](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 -独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、`fs-local`、该 shell 的工具消费方、editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 shell 消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[docs/architecture.md](../../../../docs/architecture.zh.md) 负责该组合包的位置及其与 `dsh-base` 的分离。 +独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、该 shell 的工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-local`、`fs-sandbox`、`fs-observation-policy` 或文件系统工具。持久 shell 消费该 profile 的 danger-full-access 沙箱策略。[docs/architecture.md](../../../../docs/architecture.zh.md) 负责该组合包的位置及其与 `dsh-base` 的分离。 `DSH_SYSTEM_PROMPT` 选择独立组合的 persona,`DSH_CONTEXT_WINDOW` 为没有确切目录元数据的模型提供后备容量。SDK 客户端的 JSON-RPC `initialize` 请求是唯一运行时模型选择。[`minimal.py`](../../../../python/sdk/examples/minimal.py)可以只把 `DSH_MODEL` 读作命令的默认 `model` 参数;显式 `--model` 不需要匹配的子进程环境值。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 ## 验证 -Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。 +Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言不存在作用域文件系统或压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与持久 Bash。随后,它通过真实作用域服务执行持久 Bash。 -SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确双工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 +SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确单工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 ## 考虑过的替代方案 **以较高阈值保留 `compaction-basic`。** 不予采用,因为即便提供方在短测试中未触发,较长会话仍允许替换历史记录,而且 minimal 组合仍会依赖模型容量元数据与 token meter。 -**在 danger-full-access 模式下保留 `fs-sandbox`。** 不予采用,因为沙箱提供方仍会使限权与提权成为编辑器能力的一部分。目标运行时要求裸本地提供方,而其不具备 `sandboxMode` 正是组合事实。 +**移除编辑器后保留 `fs-local`。** 不予采用,因为两份 minimal 组合都没有其他文件系统消费方。保留该提供方只会扩大运行时清单,不会新增模型可见能力。 **为 Web 与 Python SDK 启动使用同一个 Cordis leaf。** 不予采用,因为 Web preset 向现有多会话宿主贡献 agent 作用域服务,而 Python SDK 必须启动包含 JSON-RPC 服务器及其进程级依赖的完整进程。 @@ -36,4 +36,4 @@ SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境 ## 后果 -Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。编辑器可以访问运行时进程可见的任何绝对路径,且不受持久 shell 沙箱策略影响。两条启动路径共享面向模型的工具、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。 +Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。两条启动路径共享单工具、无文件系统、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 4c04945294..7e9849194b 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: 1bdca6214bf730386e27e922361bb45a3c120ecf -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 9377da8d5f3622f5faafce631ed2c6095d22560c +2026-08-15-product-subagent-noninteractive-permissions.md: 1ec45f9adafa2364f9d0f37f3914d97054942226 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: 80e99d9ee47da859456faf66560d42e73c88cf3d diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index 1bdca6214b..1ec45f9ada 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -32,7 +32,7 @@ Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny ins ### Codex -Codex defaults to `never` and accepts the three native non-interactive modes exposed by Codex 0.149.1. The Provider starts the fixed app-server command, then maps the selected mode into official `thread/start` fields because CLI-global permission flags do not configure threads created later by an app-server client: +Codex defaults to `never` and accepts the three native non-interactive modes exposed by Codex 0.153.4. The Provider starts the fixed app-server command, then maps the selected mode into official `thread/start` fields because CLI-global permission flags do not configure threads created later by an app-server client: | Value | `thread/start` fields | Native behavior | | --- | --- | --- | @@ -63,7 +63,7 @@ The foreground consumer presents the stop-reason headline, then the optional dia ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK 0.3.241 and Claude Code 2.1.241 fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex 0.149.1 app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, a rejected escalation leaves no side effect or raw command or path in the diagnostic, stderr remains Host-only, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK 0.3.263 and Claude Code 2.1.263 fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex 0.153.4 app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, a rejected escalation leaves no side effect or raw command or path in the diagnostic, stderr remains Host-only, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 9377da8d5f..80e99d9ee4 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -32,7 +32,7 @@ Claude Code 默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支 ### Codex -Codex 默认使用 `never`,并接受 Codex 0.149.1 公开的三种原生非交互模式。提供方启动固定的 app-server 命令,再把所选模式映射为官方 `thread/start` 字段,因为 CLI 全局权限 flag 不会配置之后由 app-server 客户端创建的线程: +Codex 默认使用 `never`,并接受 Codex 0.153.4 公开的三种原生非交互模式。提供方启动固定的 app-server 命令,再把所选模式映射为官方 `thread/start` 字段,因为 CLI 全局权限 flag 不会配置之后由 app-server 客户端创建的线程: | 值 | `thread/start` 字段 | 原生行为 | | --- | --- | --- | @@ -63,7 +63,7 @@ Codex 默认使用 `never`,并接受 Codex 0.149.1 公开的三种原生非交 ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK 0.3.241 与 Claude Code 2.1.241 fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex 0.149.1 app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、被拒绝的提权不会留下副作用且诊断不含原始命令或路径、stderr 只供 Host 观测,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK 0.3.263 与 Claude Code 2.1.263 fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex 0.153.4 app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、被拒绝的提权不会留下副作用且诊断不含原始命令或路径、stderr 只供 Host 观测,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml index 908d48ae05..71a7ed9371 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md -2026-08-31-cross-process-session-write-lease.md: 174c5152ea62e01e30ade9a68b6786638acb8ada -2026-08-31-cross-process-session-write-lease.zh.md: e4246f12f7ed8d8b304ca7f7514117f03f32267b +2026-08-31-cross-process-session-write-lease.md: ef8ebe2de6b231075dee31a9184bcc0a5b323011 +2026-08-31-cross-process-session-write-lease.zh.md: 8a79b6d5327c7bd5d0ca12ac435140bc0951e993 diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md index 174c5152ea..ef8ebe2de6 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md @@ -10,7 +10,7 @@ The JSONL backend's write-handle claim excluded a second writer only inside one ## Decision -`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the pinned native dependency `fs-ext`, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs fs-ext to immediate success: it is single-process, so the in-process write claim already excludes every writer. +`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the prebuilt `@deepseek-ai/node-addon-system/flock` binding, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs the flock entry to immediate success: it is single-process, so the in-process write claim already excludes every writer. ## Alternatives considered @@ -22,8 +22,8 @@ The JSONL backend's write-handle claim excluded a second writer only inside one **Windows exclusive-open sharing mode (`CreateFileW` denying `FILE_SHARE_WRITE`)** — leaves readers untouched but pins the lock file's name and directory while held: CI showed dozens of suites failing their temp-root cleanup with EBUSY because a still-open handle blocks recursive removal, and users deleting a session directory would hit the same wall. The named semaphore keeps kernel arbitration with zero filesystem footprint. -**Hand-rolled ffi for POSIX too (`flock(2)` via koffi)** — avoids the node-gyp install-time build, but means owning both platform lock implementations plus their error mapping; `fs-ext` ships the POSIX code maintained and pinned, and the Windows side reuses the koffi bindings `win32.ts` already owns. +**Hand-rolled ffi for POSIX too (`flock(2)` via koffi)** — binding selection and asynchronous errno handling are governed by the [prebuilt system primitives decision](../architecture/2026-09-07-prebuilt-system-primitives.md). The Windows side retains the koffi bindings `win32.ts` already owns. ## Consequences -Cross-process exclusion costs a node-gyp-compiled native dependency (`fs-ext`, allow-listed in `pnpm-workspace.yaml` `allowBuilds`), one lock file per materialized session that release deliberately leaves in place, and the wedged-holder rule: a stuck process blocks that session's writers until it exits. It buys immediate crash recovery (no waiting period), no renewal traffic, and the removal of every takeover race the TTL design managed rather than prevented. Advisory `flock` is unreliable on some network filesystems (NFSv3); a root on such a mount degrades toward in-process-only exclusion. Deleting a live session's lock file forfeits exclusion on POSIX by design — the harness never does so; the agent-loop resume test uses it deliberately to simulate a wedged first lifecycle, and skips on Windows, where the lock is a kernel object no file operation can forfeit. +Cross-process exclusion requires the platform's prebuilt system binding, one lock file per materialized session that release deliberately leaves in place, and the wedged-holder rule: a stuck process blocks that session's writers until it exits. It buys immediate crash recovery (no waiting period), no renewal traffic, and the removal of every takeover race the TTL design managed rather than prevented. Advisory `flock` is unreliable on some network filesystems (NFSv3); a root on such a mount degrades toward in-process-only exclusion. Deleting a live session's lock file forfeits exclusion on POSIX by design — the harness never does so; the agent-loop resume test uses it deliberately to simulate a wedged first lifecycle, and skips on Windows, where the lock is a kernel object no file operation can forfeit. diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md index e4246f12f7..8a79b6d532 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md @@ -10,7 +10,7 @@ JSONL 后端的写句柄认领只在单个后端实例内部排除第二个写 ## Decision -`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由固定版本的原生依赖 `fs-ext` 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 fs-ext 存根为立即成功:它是单进程部署,进程内写认领已排除所有写入方。 +`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由预编译 `@deepseek-ai/node-addon-system/flock` 绑定 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 flock 入口存根为立即成功:它是单进程部署,进程内写认领已排除所有写入方。 ## Alternatives considered @@ -22,8 +22,8 @@ JSONL 后端的写句柄认领只在单个后端实例内部排除第二个写 **Windows 共享模式独占打开(`CreateFileW` 拒绝 `FILE_SHARE_WRITE`)** —— 读者不受影响,但持有期间钉住锁文件的名字与目录:CI 显示数十个套件的临时根清理因仍打开的句柄阻塞递归删除而报 EBUSY,用户删除会话目录也会撞上同一堵墙。命名信号量保住内核仲裁,且文件系统足迹为零。 -**POSIX 也手写 ffi(经 koffi 调 `flock(2)`)** —— 免去 node-gyp 安装期编译,但意味着自有两个平台的锁实现及其错误映射;`fs-ext` 交付了有维护、可固定版本的 POSIX 侧,Windows 侧复用 `win32.ts` 已自有的 koffi 绑定。 +**POSIX 也手写 ffi(经 koffi 调 `flock(2)`)** —— 绑定选择与异步 errno 处理由[预编译系统原语决策](../architecture/2026-09-07-prebuilt-system-primitives.zh.md)规定。Windows 侧保留 `win32.ts` 已有的 koffi 绑定。 ## Consequences -跨进程排他的代价是一个 node-gyp 编译的原生依赖(`fs-ext`,已在 `pnpm-workspace.yaml` 的 `allowBuilds` 列入允许)、每个物化会话一个由释放刻意留下的锁文件,以及卡死持有者规则:卡住的进程阻塞该会话的写入方直到其退出。它换来的是即时崩溃恢复(无等待期)、零续约流量,以及删除了 TTL 设计只能"管理"而非"消除"的全部接管竞态。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠;位于此类挂载上的根目录会退化为仅进程内排他。POSIX 上删除活跃会话的锁文件按设计即放弃排他——harness 自身从不这样做;agent-loop 的 resume 测试刻意用它模拟卡死的第一个生命周期,并在 Windows 上跳过:那里的锁是任何文件操作都无法放弃的内核对象。 +跨进程排他需要对应平台的预编译系统绑定、每个物化会话一个由释放刻意留下的锁文件,以及卡死持有者规则:卡住的进程阻塞该会话的写入方直到其退出。它换来的是即时崩溃恢复(无等待期)、零续约流量,以及删除了 TTL 设计只能"管理"而非"消除"的全部接管竞态。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠;位于此类挂载上的根目录会退化为仅进程内排他。POSIX 上删除活跃会话的锁文件按设计即放弃排他——harness 自身从不这样做;agent-loop 的 resume 测试刻意用它模拟卡死的第一个生命周期,并在 Windows 上跳过:那里的锁是任何文件操作都无法放弃的内核对象。 diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml new file mode 100644 index 0000000000..466ef2e05f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md +2026-09-02-in-history-system-prompt-replacement.md: b34a0d2b3591c1b62aba16d79963940be787f373 +2026-09-02-in-history-system-prompt-replacement.zh.md: 296932a977ae852c4ef32de1c23b2c5d9e4a1bbf diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md new file mode 100644 index 0000000000..b34a0d2b35 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md @@ -0,0 +1,97 @@ +# Agent Note: In-history system prompt replacement for cache-stable prompt changes + +Status: implemented + +English | [中文](2026-09-02-in-history-system-prompt-replacement.zh.md) + +## Problem + +Every system prompt change costs the whole provider prefix cache. The loop renders the prompt on every step; when the bytes differ — a plan-mode section entering or leaving, a skill or tool guidance section registering, an agent-scoped persona shadow, a changed `{{model}}` variable — the request's message 0 changes and the DeepSeek context cache misses from the first token. Long agentic sessions pay this repeatedly, and the [runtime-context snapshot design](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) exists precisely because moving a changing fact out of the prompt was the only way to keep the prefix stable. + +A DeepSeek model, recorded here as a model fact supplied for this work, removes that constraint: it accepts a `system` message at any position of the conversation and treats the latest one as the complete effective system prompt, replacing the leading one. Tool schemas remain part of the cached prefix, so a tool-set change still invalidates the cache. With that model the harness can append the new prompt after the cached history instead of rewriting message 0, and the prefix stays warm. + +The harness has the representation for this because the [system prompt is surface node 0](../architecture/2026-09-02-system-prompt-as-surface-node.md): a prompt change is an operation on `system/message` surface nodes, and the choice between "replace the latest system node" and "append a new node" is a per-route decision. + +## Decision + +For a model route that declares the capability, the loop appends a new `system/message` surface node instead of replacing the latest system node when the rendered prompt changes and the prefix would otherwise survive. Everything else in the [surface-node decision](../architecture/2026-09-02-system-prompt-as-surface-node.md) is unchanged: the event type, the projection owner, the serializers, and the node 0 head protection. + +### Capability + +`dsh-llm` defines `SystemPromptUpdate = 'in-history'` and carries it as an optional sibling field, `systemPromptUpdate`, on `LlmResolvedModelInfo` and `PreparedLlmCall`; `normalizeModelInfo` rejects any other value with an `LlmError` whose code is `INVALID_MODEL_INFO`. The DeepSeek adapter's catalog model (`DeepSeekCatalogModel.systemPromptUpdate`, validated by zod at load) and the replay provider's `ReplayModelConfig.systemPromptUpdate` declare it per model; absence means the model needs message 0 rewritten. No default catalog entry declares it; a deployment enables it through the `models` list in `cordis.yml`, and every `dsh-llm-pi-ai` route keeps the replace behaviour. + +The loop records the mode in the session: `RequestContext.systemPromptUpdate` joins provider, model, and capacity as a `request/context` field, logged whenever any of them differs from the latest snapshot. Admission reads `PreparedLlmCall.systemPromptUpdate` from the actual call prepared after `agent/request`; the preceding snapshot is not an admission input. First requests, resumed sessions, route changes, and same-route capability changes therefore use the capability of the bound adapter that will serve the call. + +### The decision rule + +`SystemPromptProjection.project(rendered, { inHistory, startsSeries })` in `packages/core/agent-loop/src/runtime-context.ts` scans the surviving `system/message` nodes of the current surface on every call. It returns ordered per-node commits. With no surviving system node it reserves the head even for an empty rendering. Effective text comes from the latest non-empty system node, falling back to the head; dormant empty tails neither supply effective text nor need another empty replacement. An empty rendering clears every active system node, regardless of route or series state. For a non-empty rendering on an incapable route or at a new request series, consolidation applies even when the effective text is unchanged. Otherwise matching effective text emits nothing. The operations are: + +| Route capability | Prefix state | Operation | +|---|---|---| +| none | non-empty rendering, any prefix state | log an empty replacement for each non-empty later system node, then rewrite the first system node with the rendering if needed | +| `in-history` | the current request series continues | append a new `system/message` before the step's `user/message` events; the append alone needs no `request/header` | +| `in-history` | non-empty rendering, a new series starts | log empty replacements for non-empty later system nodes, then rewrite the first system node if needed, even when the latest effective text is unchanged | +| any | the rendered prompt is empty | log empty replacements for non-empty later system nodes, then empty the head if needed; no prompt version remains in derived messages | + +`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start already costs the cache, so consolidation keeps only the current prompt in model history. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt. + +The first attempt admits the prompt after assembly, an accepted `agent/pre-step` decision, `step/start`, the `agent/request` waterfall, and `prepareCall()`. A rejected or empty first input opens no step. Neither async request phase commits the pending system prompt or accepted users, and cancellation during either commits neither. Every attempt synchronously reconciles the same rendered assembly after its own `agent/request` and `prepareCall()`, appends the accepted user batch only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming through the same prepared call. Retries do not repeat assembly, `agent/pre-step`, or user admission. Reconciliation sees both pre-step compaction (`compaction-basic` with `auto: true`) and recovery compaction, and consolidates non-empty prompt text at the head when either starts a new series. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary. + +An empty head with no active later system node represents no prompt. Dormant empty tails do not supply effective text, so repeated clearing and resume cannot resurrect an older prompt. Restoring non-empty text uses the same admission rule: a continuing capable route may append it; an incapable route or a new series refills the head. Clearing uses ordinary per-node replacements, not a surface delete or an initial empty-head creation. + +### Presentation and accounting + +Web presents an appended in-history node at its own position. `SystemPromptNode` carries `{ seq, time, turn, step, text, update }`, `update` being true for an appended `system/message` that follows an earlier system node in the loaded window. Chat renders a non-empty update as a collapsed `system-prompt` card titled by the locale key `message.systemPromptUpdate`, and a `request/header` in the same turn and step does not repeat the prompt card; `inspectRequestPrompt` reports no system change for a header that follows an update. Trajectory folds an update following a loaded request header into a synthetic request-header fact with `promptChange.kind = 'system'`, so later requests show the effective prompt without a real header change. When the loaded window lacks the earlier system node, the update is presented as an initial prompt. Transcript projections skip it like every `system/message`. + +`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The sum equals the fixed-heuristic surface total after every replacement, whether or not a shadow-price claim exists. Compact retained entries reuse the measurement surface planner: state and transitions cost O(current retained surface), not O(1) or O(total historical log). Replaced entries and message bodies are discarded, and state version 4 rejects scalar checkpoints. `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect. + +Trajectory chooses the newer of the preceding real header and the preceding synthetic system header as the comparison state. A real header owns configuration and tools; an appended prompt can advance that state without another real header. Comparing only real headers would report A rather than B as the previous prompt for an A → B → C sequence. + +Chat and Trajectory interpret the effective prompt through the pure `uiConversation.inspectSystemPrompt` operation. Each target keeps immutable prefix states for system events and positional replacements, with only surviving system nodes and a map of surviving replacement sequences to inherited surface positions. Each replacement copies that map and removes shadowed entries; historical prefix maps remain immutable. Ordinary appends and streaming updates require no prompt fold. Surface order, rather than event order or provenance citations, determines which nodes survive: a compaction can restore an older prompt without another system event, and a head rewrite can have a greater sequence than a later active prompt. Empty nodes remain addressable but do not override a nonempty prompt. An endpoint older than the earliest relevant loaded event has unknown order unless its replacement position is indexed. The interpreter withholds all subsequent prompt text after such an endpoint until prepend replay resolves the missing prefix; numeric event order cannot establish surface order. Historical cards keep their own prefix state rather than reading the final surface. + +### Compaction + +`compaction-basic` is unchanged. `selectCompactableRange` still anchors at the first non-system node, so node 0 is never shadowed and later in-history nodes can be; `buildSummarizationInput` prepends the derived head to `messages`, followed by every shadowed node's derived message in surface order, so a mid-region system node is replayed in place and the summarization call remains a genuine prefix of the conversation. + +## Alternatives considered + +**Send only the changed sections as a delta.** The model treats the latest system message as the complete prompt, so a delta would silently drop every unchanged section. Rejected on the model contract. + +**Enable in-history mode by plugin config instead of a model capability.** A deployment flag could pair a non-capable model with appended system messages, which such a model would read as ordinary history at best. The capability belongs to the route that honours it; the adapter catalog already carries per-model capacities. Rejected. + +**Always append, never re-baseline.** One rule, but node 0 would stay stale for the life of the session and every request after compaction would carry the stale head plus the replacement. Re-baselining at a series start costs nothing extra because the cache is already lost there. Rejected. + +**Re-baseline on every resume.** Accepts one cache miss per process restart for a simpler resume path. The cache persists across restarts for hours to days, and the log already carries what resume needs. Rejected. + +**Place the system message after the step's user messages.** Both positions sit after the cached prefix, but the model then reads the instructions after the input it must apply them to; system-before-user matches the leading position's ordering. Rejected. + +**Project the prompt before the `agent/pre-step` waterfall.** The projection would not see a compaction performed inside the waterfall, so a just-appended node could be shadowed in the same step and the request would carry node 0's stale prompt as the only system message. Projecting after the waterfall keeps the rule a pure function of the surface the request is built from. Rejected. + +**Use the preceding request context for admission.** It describes the previous call, not the adapter bound after request middleware. It can select the wrong prompt representation on the first call, after resume, or after a route or capability change. Resolving before prompt and user commits also keeps cancellation from admitting unsent content. Rejected. + +**Treat a provider or model swap as a series start.** It would fold the prompt into node 0 on every route change, matching the tools case. The header already records the change and the cache misses either way, so the extra rule bought nothing but a special case in the loop. Rejected. + +**Clear only the latest system node.** Empty nodes project to no message, so an older prompt would become effective again. Clearing all active versions preserves the meaning of an empty rendering without deleting conversation history. Rejected. + +**Keep only scalar totals or prompt ancestry.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Prompt-only entries cannot locate arbitrary nonprompt replacement endpoints; `sourceEventSeqs` may also cite surviving prompts, and event sequence order differs from surface order after rewrites. Retaining compact current surface entries reuses the existing planner without full-log access, a second validator, or consumer-specific durable events. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification. + +## Consequences + +- A prompt change on a capable route keeps the provider prefix cache; the appended node costs its own tokens on every request in the series until compaction shadows it. A deployment whose prompt changes on most steps is better served by moving that fact into runtime context. +- The request head is not the only place a system prompt can live: readers of "what did the model see" fold the surface and take the latest system node, and the breakdown's system figure follows the same rule. +- A `request/context` snapshot records the prepared route and declared mode; it describes admission rather than deciding it. Incapable-route consolidation is logged per system node, preserving intervening user, assistant, and tool history. +- The model contract is recorded as supplied. If a released model narrows it — for example honouring only the latest system message within a bounded window — the rule needs a re-baseline trigger beyond series starts. +- A proxy that rewrites or reorders system messages breaks the replacement semantics silently; the real-API e2e's cache-hit assertion is the detector. + +## Testing + +Lifecycle verification requires no event for an unchanged prompt and an appended changed prompt after resume on a capable route. Both TypeScript and Python SDK expected outputs must include the typed appended `system/message` event, as required by the [SDK snapshot policy](../../../../docs/testing.md). The [TypeScript SDK notifications](../../../../snapshots/sdk/system-prompt-in-history/notifications.expected.jsonl) and [Python SDK prompt history](../../../../scripts/snapshots/python-sdk-single-exe/minimal-in-history/prompt-history.json) record the appended prompt event and retained prompt versions. + +- `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` covers capable-to-incapable routing with changed or unchanged text, incapable-to-capable routing, resumed-route admission, cancellation in request middleware or preparation, and a concurrent selection change while the prepared route stays bound. Retry-compaction cases shadow the latest prompt with or without an earlier surviving update and verify reuse of the admitted assembly, one user admission, and no extra series header on an unchanged retry. Clear cases on capable and incapable routes remove three active prompt versions, keep repeated requests and seeded resume empty without extra prompt events, and restore only the new text; log reconstruction and the pi converter retain no old instructions. Focused coverage of `src/agent.ts` and `src/runtime-context.ts` reaches 100% for statements, branches, functions, and lines. +- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on a continuing series, the re-baseline at a series start with or without surviving later nodes and with changed or unchanged effective text, the empty-prompt clearing of all active versions, and the replace-only behaviour without the capability. +- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` pins the appended node under an inherited header with `request/context` carrying `systemPromptUpdate`, the series-start fold into node 0, the compaction-driven re-baseline, and the tool-schema change re-baseline under a `change` header that starts a series. +- `packages/llm/llm/tests/service.spec.ts`, `packages/llm/llm-deepseek/tests/adapter.spec.ts`, and `packages/test-support/llm-replay/tests/llm-replay.spec.ts` pin the declared mode on resolved model info and the load-time rejection of any other value. +- `packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` pins newest/middle prompt removal, exact heuristic totals, surface ordering after head rewrites, extra provenance citations, dormant empties and fallback clears, immutable transitions, compact retained checkpoints, late registration, replay, and version invalidation. +- `packages/client/ui-conversation`, `ui-chat`, and `ui-trajectory` client specs pin the update card, the same-step header dedupe, the absent system change after an update, and the synthetic trajectory header. +- The keyless authored snapshot `snapshots/session/system-prompt-in-history/` declares the capability on the replay route, changes the prompt after the first tool call through a fixture section, and pins the appended `system/message`, the untouched node 0, the single `request/header`, and the `request/context` mode. +- `packages/llm/llm-deepseek/tests/adapter.e2e.ts` runs a two-step prompt change against the model named by `DEEPSEEK_IN_HISTORY_MODEL`, asserts that the reply follows the appended prompt, and asserts that the appended request reads more cached tokens than the same conversation with a rewritten leading prompt; it skips when the variable is unset. diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md new file mode 100644 index 0000000000..296932a977 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md @@ -0,0 +1,97 @@ +# Agent Note: 历史内系统提示词替换,实现缓存稳定的提示词变更 + +Status: implemented + +[English](2026-09-02-in-history-system-prompt-replacement.md) | 中文 + +## Problem + +每一次系统提示词变更都要付出整个提供方前缀缓存的代价。循环在每个步骤渲染提示词;一旦字节不同——plan 模式片段进入或退出、某个 skill 或工具指引片段完成注册、agent 作用域的 persona 遮蔽、`{{model}}` 变量改变——请求的消息 0 随之改变,DeepSeek 上下文缓存从第一个 token 起失效。长时间的 agent 会话反复为此付费,而[运行时上下文快照设计](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)之所以存在,正是因为把会变化的事实移出提示词是保持前缀稳定的唯一办法。 + +一个 DeepSeek 模型——在此按为本项工作提供的模型事实记录——移除了这一限制:它接受对话任意位置的 `system` 消息,并把最新一条视为完整的有效系统提示词,替换最前面那条。工具 schema 仍属于被缓存的前缀,因此工具集变更仍会使缓存失效。有了这样的模型,harness 可以把新提示词追加到已缓存的历史之后而不是重写消息 0,前缀就能保持热态。 + +因为[系统提示词是 surface 第 0 号节点](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md),harness 拥有实现这一点的表示:提示词变更是对 `system/message` surface 节点的操作,而「替换最新的系统节点」与「追加新节点」之间的选择是逐路由的决定。 + +## Decision + +对于声明了该能力的模型路由,当渲染后的提示词变化且前缀本可存活时,循环追加一个新的 `system/message` surface 节点而不是替换最新的系统节点。[surface 节点决策](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md)中的其他一切不变:事件类型、投影的拥有者、序列化器,以及第 0 号节点的头部保护。 + +### 能力 + +`dsh-llm` 定义 `SystemPromptUpdate = 'in-history'`,并把它作为可选的并列字段 `systemPromptUpdate` 放在 `LlmResolvedModelInfo` 与 `PreparedLlmCall` 上;`normalizeModelInfo` 用代码为 `INVALID_MODEL_INFO` 的 `LlmError` 拒绝任何其他值。DeepSeek 适配器的目录模型(`DeepSeekCatalogModel.systemPromptUpdate`,加载时由 zod 校验)与回放提供者的 `ReplayModelConfig.systemPromptUpdate` 逐模型声明它;缺省表示该模型需要重写消息 0。没有默认目录条目声明它;部署方通过 `cordis.yml` 的 `models` 列表启用,所有 `dsh-llm-pi-ai` 路由保持替换行为。 + +循环把该模式记录进会话:`RequestContext.systemPromptUpdate` 与 provider、model、容量并列成为 `request/context` 的字段,其中任一项与最新快照不同时就记录一次。准入读取 `agent/request` 之后实际准备调用的 `PreparedLlmCall.systemPromptUpdate`;先前快照不是准入输入。因此首次请求、恢复的会话、路由变更以及同一路由的能力变更,都使用将服务该调用的绑定适配器的能力。 + +### 决策规则 + +`packages/core/agent-loop/src/runtime-context.ts` 中的 `SystemPromptProjection.project(rendered, { inHistory, startsSeries })` 每次调用都扫描当前 surface 上存活的 `system/message` 节点。它返回有序的逐节点提交。没有存活的系统节点时,即使渲染文本为空也预留头节点。有效文本取自最新的非空系统节点,没有时回退到头节点;未生效的空尾节点既不提供有效文本,也无需再次以空内容替换。无论路由或序列状态如何,空渲染文本都会清除每个生效的系统节点。不具备能力的路由或新请求序列面对非空渲染文本时,即使有效文本未变也执行归并。除此之外,有效文本相同时不产生事件。具体操作如下: + +| 路由能力 | 前缀状态 | 操作 | +|---|---|---| +| 无 | 非空渲染文本,任意前缀状态 | 为每个非空的后续系统节点记录空内容替换,随后按需用渲染文本重写首个系统节点 | +| `in-history` | 当前请求序列延续 | 在该步骤的 `user/message` 事件之前追加新的 `system/message`;仅追加本身不需要记录 `request/header` | +| `in-history` | 非空渲染文本,新序列开始 | 为非空的后续系统节点记录空内容替换,再按需重写首个系统节点,即使最新有效文本未变也执行 | +| 任意 | 渲染后的提示词为空 | 为非空的后续系统节点记录空内容替换,再按需清空头节点;派生消息中不保留任何提示词版本 | + +`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、可见工具 schema 集合发生了变化。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始已经付出了缓存代价,因此归并让模型历史只保留当前提示词。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。 + +首次尝试在组装、被接纳的 `agent/pre-step` 决策、`step/start`、`agent/request` waterfall 与 `prepareCall()` 之后才接纳提示词。被拒绝或为空的首次输入不打开步骤。两个异步请求阶段都不提交待处理的系统提示词与已接纳用户消息,在任一阶段取消都不会提交这两者。每次尝试都在各自的 `agent/request` 与 `prepareCall()` 之后同步协调同一份已渲染组装结果、仅在首次尝试追加已接纳用户批次、按需记录 header/context、派生并冻结请求,再通过同一个已准备调用发起流式请求。重试不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 压缩(`auto: true` 的 `compaction-basic`)与恢复压缩,并在任一种压缩开启新序列时将非空提示词文本归并到头部。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。 + +空头节点且没有生效的后续系统节点表示没有提示词。未生效的空尾节点不提供有效文本,因此重复清除与恢复会话都不会使旧提示词重新生效。重新提供非空文本使用同一准入规则:延续中的具备能力路由可以追加它;不具备能力的路由或新序列则重新填充头节点。清除使用普通的逐节点替换,而非 surface 删除或初次创建空头节点。 + +### 呈现与记账 + +Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携带 `{ seq, time, turn, step, text, update }`,其中 `update` 对已加载窗口内跟在更早系统节点之后的追加 `system/message` 为真。Chat 把非空的更新渲染为一张折叠的 `system-prompt` 卡片,标题取自 locale 键 `message.systemPromptUpdate`,同一 turn 与 step 内的 `request/header` 不会重复提示词卡片;`inspectRequestPrompt` 对跟在更新之后的 header 不报告系统变更。Trajectory 把跟在已加载请求 header 之后的更新折叠为一条合成的请求 header 事实,`promptChange.kind = 'system'`,因此之后的请求无需真实的 header 变更就能显示有效提示词。已加载窗口缺少更早的系统节点时,更新按初始提示词呈现。转录投影像对待所有 `system/message` 一样跳过它。 + +`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。每次替换后,两者之和都等于固定启发式 surface 总量,无论是否存在影子价 claim。紧凑的保留条目复用测量服务的 surface 规划器:状态和转换成本为 O(当前保留 surface),不是 O(1) 或 O(完整历史日志)。被替换条目和消息正文被丢弃,状态版本 4 拒绝标量检查点。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。 + +Trajectory 选择前一条真实 header 与前一条合成系统 header 中较新的一个作为比较状态。真实 header 拥有配置与工具;追加的提示词可以在没有另一条真实 header 时推进该状态。只比较真实 header 会在 A → B → C 序列中把 A 而不是 B 报告为先前提示词。 + +Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释有效提示词。每个 target 为系统事件与位置替换保留不可变的前缀状态,其中只包含存活系统节点,以及将存活替换序号映射到继承 surface 位置的映射表。每次替换复制该表并删除被遮蔽的条目;历史前缀映射表保持不可变。普通追加与流式更新无需折叠提示词。节点是否存活由 surface 顺序决定,而不是事件顺序或来源引用:压缩可以在没有另一个系统事件的情况下恢复更早的提示词,头部重写的序号也可能大于更后位置的有效提示词。空节点仍可被定位,但不会覆盖非空提示词。早于最早已加载相关事件的端点,其顺序未知,除非已有替换位置索引。遇到这样的端点后,解释器会暂停公开之后的所有提示词文本,直到向前补页回放解析缺失的前缀;事件序号顺序不能确定 surface 顺序。历史卡片保留自己的前缀状态,而不是读取最终 surface。 + +### 压缩 + +`compaction-basic` 不变。`selectCompactableRange` 仍锚定在第一个非系统节点,因此第 0 号节点永不被遮蔽,更后的历史内节点则可能被遮蔽;`buildSummarizationInput` 将派生的头节点前置到 `messages`,再按 surface 顺序加入每个被遮蔽节点的派生消息,因此区域中途的系统节点在原位被回放,摘要调用仍是对话的真实前缀。 + +## Alternatives considered + +**只发送变化的片段作为增量。** 模型把最新的系统消息当作完整提示词,因此增量会静默丢掉每个未变化的片段。基于模型约定被否决。 + +**用插件配置而不是模型能力启用历史内模式。** 部署标志可能把不具备能力的模型与追加的系统消息配对,这样的模型最多把它们当作普通历史。该能力属于兑现它的路由;适配器目录已经承载逐模型的容量信息。被否决。 + +**永远追加,从不重新基线化。** 规则单一,但第 0 号节点会在会话整个生命周期内保持过时,压缩之后的每个请求都要携带过时的头部加替换消息。在序列开始处重新基线化不花额外代价,因为缓存在那里已经丢失。被否决。 + +**每次恢复都重新基线化。** 为更简单的恢复路径接受每次进程重启一次缓存未命中。缓存跨重启持续数小时到数天,而日志已经承载恢复所需的一切。被否决。 + +**把系统消息放在该步骤的用户消息之后。** 两个位置都在已缓存前缀之后,但模型会在读到必须应用指令的输入之后才读到指令;system 在 user 之前与最前位置的顺序一致。被否决。 + +**在 `agent/pre-step` waterfall 之前投影提示词。** 投影将看不到在该 waterfall 内执行的压缩,刚追加的节点可能在同一步骤内被遮蔽,请求就会把第 0 号节点的过时提示词作为唯一的系统消息携带。在 waterfall 之后投影让规则保持为构建请求所用 surface 的纯函数。被否决。 + +**用先前的请求上下文决定准入。** 它描述上一次调用,而非请求中间件之后绑定的适配器。在首次调用、恢复之后、路由或能力变更之后,它可能选错提示词表示。在提交提示词与用户消息之前解析,还能防止取消时接纳未发送的内容。被否决。 + +**把 provider 或 model 切换视为序列开始。** 它会在每次路由变更时把提示词折回第 0 号节点,与 tools 的情形一致。header 已经记录了该变更,缓存无论如何都会未命中,因此这条额外规则除了在循环中多一个特例之外没有任何收益。被否决。 + +**仅清除最新系统节点。** 空节点不投影为消息,因此更早的提示词会重新生效。清除所有生效版本才能保留空渲染文本的含义,同时不删除对话历史。被否决。 + +**只保留标量总量或提示词祖先链。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。仅有提示词条目无法定位任意非提示词替换端点;`sourceEventSeqs` 还可能引用存活提示词,改写后的事件序号顺序也不同于 surface 顺序。保留紧凑的当前 surface 条目可以复用现有规划器,无需完整日志访问、第二套验证器或消费方专用持久事件。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。 + +## Consequences + +- 具备能力的路由上的提示词变更保住提供方前缀缓存;追加的节点在该序列的每个请求上付出自身的 token 开销,直到压缩遮蔽它。提示词在多数步骤都变化的部署,更适合把那个事实移入运行时上下文。 +- 请求头部不是系统提示词唯一可能的位置:「模型看到了什么」的读者折叠 surface 并取最新的系统节点,明细的系统数字遵循同一规则。 +- `request/context` 快照记录已准备的路由与声明模式;它描述准入结果,而不决定准入。不具备能力的路由逐系统节点记录归并,保留其间的用户、assistant 与工具历史。 +- 模型约定按所提供的内容记录。若发布的模型收窄了约定——例如只在有界窗口内兑现最新的系统消息——规则需要序列开始之外的重新基线化触发条件。 +- 重写或重排系统消息的代理会静默破坏替换语义;真实 API e2e 的缓存命中断言是探测器。 + +## Testing + +生命周期验证要求:提示词未变更时不产生事件,具备能力的路由在恢复后追加变更后的提示词。TypeScript 与 Python SDK 的期望输出都必须包含带类型的追加 `system/message` 事件,遵循 [SDK 快照策略](../../../../docs/testing.zh.md)。[TypeScript SDK 通知](../../../../snapshots/sdk/system-prompt-in-history/notifications.expected.jsonl)与 [Python SDK 提示词历史](../../../../scripts/snapshots/python-sdk-single-exe/minimal-in-history/prompt-history.json)记录了追加的提示词事件与保留的提示词版本。 + +- `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` 覆盖文本变化或未变时从具备能力切换到不具备能力的路由、反向路由切换、恢复时的路由准入、请求中间件或准备阶段取消,以及已准备路由保持绑定时并发选择发生变化。重试压缩用例覆盖遮蔽最新提示词后有或没有更早更新存活的情况,并验证复用已接纳的组装结果、用户消息仅接纳一次,以及未变的后续重试不会多记序列 header。具备和不具备能力路由的清除用例会移除三个生效提示词版本,验证重复请求与带 seed 的恢复保持为空且不多记提示词事件,并仅恢复新文本;日志重建与 pi 转换器都不保留旧指令。`src/agent.ts` 与 `src/runtime-context.ts` 的聚焦覆盖率在语句、分支、函数和行四项均达到 100%。 +- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住序列延续时的追加、序列开始时无论是否存在后续存活节点、有效文本是否变化都执行的重新基线化、空提示词对所有生效版本的清除,以及不具备能力时只做替换的行为。 +- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` 钉住继承 header 下追加的节点及携带 `systemPromptUpdate` 的 `request/context`、序列开始时折回第 0 号节点、由压缩驱动的重新基线化,以及在开启序列的 `change` header 下由工具 schema 变更驱动的重新基线化。 +- `packages/llm/llm/tests/service.spec.ts`、`packages/llm/llm-deepseek/tests/adapter.spec.ts` 与 `packages/test-support/llm-replay/tests/llm-replay.spec.ts` 钉住已解析模型信息上声明的模式,以及加载时对任何其他值的拒绝。 +- `packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` 钉住最新与中间提示词移除、精确启发式总量、头部改写后的 surface 顺序、额外来源引用、休眠空节点与回退清空、不可变转换、紧凑保留检查点、延迟注册、重放和版本失效。 +- `packages/client/ui-conversation`、`ui-chat` 与 `ui-trajectory` 的客户端测试钉住更新卡片、同一步骤 header 的去重、更新之后不存在系统变更,以及合成的轨迹 header。 +- 无密钥的手写快照 `snapshots/session/system-prompt-in-history/` 在回放路由上声明该能力,通过 fixture 片段在第一次工具调用之后改变提示词,钉住追加的 `system/message`、未被触及的第 0 号节点、唯一一条 `request/header` 以及 `request/context` 中的模式。 +- `packages/llm/llm-deepseek/tests/adapter.e2e.ts` 针对 `DEEPSEEK_IN_HISTORY_MODEL` 指定的模型运行两个步骤并夹带一次提示词变更,断言回复遵循追加的提示词,并断言追加后的请求比同一对话在重写最前提示词时读取更多的缓存 token;该变量未设置时跳过。 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml index a9da3ca3f4..f9094ecb23 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md -2026-09-04-right-sidebar-docking-infrastructure.md: 8e1d1b5518c3b80a05be877ee9796883b6eb9a60 -2026-09-04-right-sidebar-docking-infrastructure.zh.md: 6830bf74734bbc8072b201ded6194e1d20ddbc88 +2026-09-04-right-sidebar-docking-infrastructure.md: 3600a17fdd0659c4f235922fa1bc905f03f5841e +2026-09-04-right-sidebar-docking-infrastructure.zh.md: 2a9ddfb7ec1fa34cefe434692829939767a6b710 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md index 8e1d1b5518..3600a17fdd 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md @@ -37,6 +37,8 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes; ### State +[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes explicit last-tab closing and default-guide reseeding here; moving tabs still settles emptied panes. + `ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the guide tab is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. ### Beyond the surface diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md index 6830bf7473..2a9ddfb7ec 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md @@ -37,6 +37,8 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 ### 状态 +[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代此处的显式关闭最后一个 tab 和默认补入引导页;移动 tab 仍会处理被清空的格。 + `ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上引导 tab——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 ### 面之外 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index e0a9906fd6..831ed01531 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: a21946c73302561a2cb8539ca314e0a5d4fb25b8 -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: dc02c159e0e052a21f2f1c80ac2e6f30bf5cf416 +2026-09-05-sidebar-text-preview-and-file-tree.md: 1a743c35634c5772e8173b770228a7f736a9d710 +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 72253286862f1e8729683fb719e83cfcf544d8e8 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index a21946c733..1a743c3563 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -12,10 +12,12 @@ Each answer carries product rules that code alone does not explain: why a text f ## Decision -Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the **text preview** (`ui-sidebar-textpreview`), and the **file tree** (`ui-sidebar-files`). Each registers a static definition into `ctx.sidebarRightTabs` and a body into the keyed `sidebar.right.pane.tab` seat under the definition's `id`, inside its own `ctx.effect`, so the type exists exactly as long as its plugin. The guide and the tree are page types opened by kind; the text preview is a viewer that claims every `file` resource address at the lowest band. A type's controls live in its own body; the pane's tab strip carries only the panel's actions. Copy is locale-owned in each package's namespace (`sidebarRight`, `sidebarTextpreview`, `sidebarFiles`). +Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the **document preview** (`ui-sidebar-documentpreview`), and the **file tree** (`ui-sidebar-files`). Each registers a static definition into `ctx.sidebarRightTabs` and a body into the keyed `sidebar.right.pane.tab` seat under the definition's `id`, inside its own `ctx.effect`, so the type exists exactly as long as its plugin. The guide and the tree are page types opened by kind; the document preview is a viewer that claims Session-scoped `file` resource addresses at the lowest band. A type's controls live in its own body; the pane's tab strip carries only the panel's actions. Copy is locale-owned in each package's namespace (`sidebarRight`, `sidebarDocumentPreview`, `sidebarFiles`). ### The guide +[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes this section's default-guide selection; guide registration, replacement and uniqueness remain unchanged. + The guide is what a pane shows before it holds content. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }` with no `patterns`: a guide views nothing, so it is opened by kind through `openTab` and recorded under the page address `sidebar://guide`, which is the registry's bookkeeping and never composed by a caller. The tab's title is `开始` / `Start`, captured into the layout record when the pane is seeded, so a later language change relabels the type and not tabs already open. The body is a centred column — a lead line (`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`), one line of copy (`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`), and a grid of entry boxes at most 480px wide, each box at least 160px, filling as many columns as fit. The boxes are projected from every registered type's `guide[]` in `order`, through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. A box shows the contributing type's glyph, title, and description, and picking it calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened. @@ -26,21 +28,23 @@ A pane holds at most one guide, and the docking layer enforces it as product beh ### The text preview -`text` is the fallback viewer for every file. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-textpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', title: basenameOf }`. The pattern contains `:` and so matches the whole address; `fallback` is the lowest band, so a type at `extension` or `builtin` with a narrower pattern (`*.png`, say) takes those addresses and everything else lands here, while the text type stays in the candidate list for any file. The `id` is the package name and doubles as the `key` of the body seat, so an extension that takes the `text` kind over cannot make the seat pick up this body by mistake. The title is the address's decoded last segment: the whole address stays the content identity — two files with one name in different directories, or one path under two sessions, are two tabs — and only the chip text is shortened. +The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) supersedes this section's renderer, loading, and resource-observation details. The fallback tab registration, paged source navigation, and body-owned controls remain in force. -A tab's address is `dsh-resource://file/session//` or `dsh-resource://file/absolute/` ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers in `dsh-util-workspace-path`). The preview never splits the string itself: `hostFileOf` in `rpc.ts` calls `parseFileAddress` and yields the `{ sessionId, path }` the endpoint takes — a `session` address reads under the session it names with the relative path the Host resolves, an `absolute` address reads under the session the slot was mounted for with the absolute path — and a malformed address throws, a programming error, because the registry routes every `file` address to this type and a caller building one is expected to use the helper. +`text` is the fallback viewer for Session-scoped files. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', canOpen, title: basenameOf }`. `canOpen` accepts only addresses whose parsed scope is `session`. The pattern contains `:` and so matches the whole address; `fallback` is the lowest band, so a type at `extension` or `builtin` with a narrower pattern (`*.png`, say) takes those addresses and everything else lands here, while the text type stays in the candidate list for any file. The `id` is the package name and doubles as the `key` of the body seat, so an extension that takes the `text` kind over cannot make the seat pick up this body by mistake. The title is the address's decoded last segment: the whole address stays the content identity — two files with one name in different directories, or one path under two sessions, are two tabs — and only the chip text is shortened. -Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `{ absolutePath, version, bytes, changed }` from the `file` provider; the body reads `changed` and the resource's failed state. Content is the type's own business, read one page of lines at a time through `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` with no `limit`, so the page length is the Host's configured cap (`maxLines`, 5000 lines by default, and a page may not exceed `maxBytes`, 2 MB by default). The first mount reads the first page; a **Load more** button at the end of the loaded text reads the next page until `eof`, disabled and reading `正在读取…` / `Reading…` while a read is in flight, and absent once the file has ended or a page failed. Pages are appended in file order with no separators and no line numbers, each carrying its line count (`lines`) so one empty line and a page past the end read differently. A first page from a newer file version replaces the pages of the older one; a later page from a newer version is not adopted and the walk restarts from the first page, so the body never shows two versions at once. The face keeps a request generation per tab: a reload bumps it, and a page settling from an older generation writes nothing. A tab switched away from and back reads nothing, because the pages live in the store, not the body. +A tab uses `dsh-resource://file/session//`, where the path may be relative or absolute ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers). `hostFileOf` accepts only this Session scope and takes both Session and path from the address; a Session-less `absolute` address is not claimed. A malformed claimed address throws as a programming error. + +Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `WorkspaceFileStat`; the body compares that observed version with the version of its loaded content. The Preview face reads text through `remote.workspaceFiles.read` and complete bytes through `readAll`. A later text page from a newer version restarts from page one, and a retired request cannot write after reload or tab disposal. The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) owns renderer-specific loading. The store is Slot-standard: one exclusive instance per session, bucketed by tab id, holding `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`. Bucketing by tab, not by file, is deliberate — two tabs of one file scroll independently. The face (`loadPage`, `reloadPages`) is the only asynchronous half: it marks a read in flight, awaits the Remote result, and writes a page or a failure through the store's actions, writing nothing if the owner's `signal` has fired. The `signal` also ends the bucket: the face arms one abort listener per tab at the tab's first read, and that listener forgets the bucket — not the body, which mounts and unmounts as tabs switch; a tab that never read has no bucket and no listener, and a record can end while its body is unmounted behind another tab. Scroll offset, wrap, and the navigation already answered therefore outlive the body: a tab comes back where the reader left it rather than re-reading or jumping again. Nothing persists across a page reload. Navigation is a `line`. The `read` tool row passes its 1-based `offset` as `openResource(address, { params: { line } })`, and the produced-file chip passes nothing; the body narrows `navigation.params` to `SidebarRightResourceParamsMap['file']` (`{ line?: number }`, declared by the `file` type's owner) without runtime validation, because caller and body meet at a typed same-process boundary. If the loaded pages do not reach the line, the body reads the next page, again, until they do or the file ends — pages load in order; there is no seek — then scrolls the line to the top of the body and highlights it, once per `navigation.revision`. The store records the answered revision, so a body remounting for the same revision restores the scroll offset instead of jumping, and a new `openResource` for the same file (revealed, not duplicated) arrives as a new revision and jumps again. A line past the end of the file stops silently at `eof`; a page that fails while walking stops the walk and shows the failure line. -A changed file is announced, not applied. When the `file` resource reports `changed` — the agent wrote the file through a tool after the last `stat` — a bar above the path row says `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.` with a `重新载入` / `Reload` button. Only the click does two things at once: `meta.reload()` (a fresh `stat`, which clears `changed`) and `reloadPages` (drop every page, read the first one again). The scroll offset is kept, so the reader stays where they were. Nothing else triggers a reload: the tree and the preview do not watch the filesystem, and an external edit is not announced. A resource that turns `failed` — the file deleted, or the Host refusing it — puts a failure bar in the same place, its line from `failure-line.ts` and the same reload button, ahead of any pending `changed`; the pages already read stay beneath it. +A changed file is announced, not applied. The body compares the loaded version and the observation captured at read start with later `WorkspaceFileStat.version`; a difference shows the change bar. Reload rereads only this tab through the Preview face and does not mutate shared resource metadata or another tab. A resource failure takes the same bar's place above any content already loaded. -The body's header is one row: the file's path as the address names it on the left (12px, tertiary colour, one line, ellipsis when it overflows, full path on hover) and two 24px controls at its right end — a wrap toggle (`自动换行` / `Wrap lines`, pressed state shown, **on by default** per tab: long lines wrap and never scroll horizontally until the reader turns it off, whereupon the file body scrolls horizontally on its own) and a reload button (`重新读取文件` / `Read the file again`) that does exactly what the change bar's button does. Neither control is ever disabled. The preview takes the pane body's full height (`height: 100%` against the pane body, which is a block scroller of definite height) so a short file leaves no separately styled space below it, and the file body — monospace, 13px, line height 1.6, 10px vertical padding — is the only scroller: the header and the change bar stay put while a long file scrolls under them. +The body's header is one row: the full file path on the left and the matching-renderer menu, conditional wrap toggle, and reload button on the right. The [Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns the current controls and renderer behavior. The preview takes the pane body's full height, and its document body is the scroller beneath the fixed header and change bar. -A failed page keeps the pages already shown and adds one sentence at the end of the loaded text, in terms of the file rather than the transport, with a `重试` / `Retry` button that reads the same page again: `workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`; `workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`; `workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.` with the byte cap rendered as `2 MB`; `workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`; `workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`; any other failure, carrier or unclassified, `读取失败:{message}` / `Read failed: {message}` with the failure's own message. The mapping lives in `failure-line.ts`, apart from the component so it is testable on its own; a code the reader does not name falls to the generic line carrying the carrier's message. A directory or a binary file therefore shows one failure line and nothing else; an empty file shows the header and an empty body with no marker. +A failed read keeps content already shown and adds a localized failure with a retry action. The Preview names actionable file failures and falls back to the carrier message for other codes; `outside-workspace` belongs to directory listing and is not a Preview-specific failure. ### The file tree @@ -94,7 +98,7 @@ Copy is the `sidebarFiles` namespace, thirteen keys. Row states: `loading` 「 ## Consequences -- A type written outside `ui-sidebar-right` has a complete template: `ui-sidebar-textpreview` shows a viewer with an address-derived read, an exclusive Slot store bucketed by tab, an inject face, typed navigation params, and body-owned controls; `ui-sidebar-files` shows a page type with a guide entry and a lazily filled store; the guide shows a chain fallback. +- A type written outside `ui-sidebar-right` has a complete template: `ui-sidebar-documentpreview` shows a viewer with an address-derived read, an exclusive Slot store bucketed by tab, an inject face, typed navigation params, and body-owned controls; `ui-sidebar-files` shows a page type with a guide entry and a lazily filled store; the guide shows a chain fallback. - Reading by page bounds every request (`maxLines` lines, `maxBytes` bytes) at the cost of a **Load more** control, no total line count, and sequential walks to a deep line; a navigation to line 40,000 of a large file reads eight pages first. - Announcing a change instead of applying it keeps the reader's place during an agent's repeated writes, at the cost of showing stale text until the reader clicks; an external edit is never announced. - Reload reads the first page only, so a reader deep in a file reloads into the top of it and pages forward again; the scroll offset is preserved but may point past the loaded text. @@ -109,10 +113,9 @@ The text preview's `tests/` cover the registry claim and yielding (through the r ## Deferred - Virtualized or seekable page loading (pages load in order), a reload that restores the loaded range, throttled scroll persistence, and a wrap icon in `ui-primitives`. -- Line numbers, syntax highlighting, rendered Markdown, images, and search in the text preview; a total line count or end-of-file marker. +- Images, search, a total line count, and an end-of-file marker. - Search, an artifact filter, drag-and-drop, rename, a context menu, current-file highlight, filesystem watching, and browsing above the workspace root in the file tree. - Product review of the guide's copy, and the guide's behaviour when a type contributes several entries. -- Chinese README counterparts for `ui-sidebar-textpreview` and `ui-sidebar-files`. ## Related diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index dc02c159e0..7225328686 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -12,10 +12,12 @@ Status: implemented ## Decision -Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、**文本预览**(`ui-sidebar-textpreview`)与**文件树**(`ui-sidebar-files`)。每个类型都在自己的 `ctx.effect` 里把静态定义注册进 `ctx.sidebarRightTabs`、把体注册进 keyed 坑位 `sidebar.right.pane.tab`(键 = 定义的 `id`),因此类型的寿命恰等于其插件。引导页与文件树是按 kind 打开的页类型;文本预览是以最低档认领每个 `file` 资源地址的查看器。类型的控件住在自己的体里;pane 的 tab 条只承载面板自身的动作。文案由各包的命名空间(`sidebarRight`、`sidebarTextpreview`、`sidebarFiles`)以 locale 方式持有。 +Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、**文档预览**(`ui-sidebar-documentpreview`)与**文件树**(`ui-sidebar-files`)。每个类型都在自己的 `ctx.effect` 里把静态定义注册进 `ctx.sidebarRightTabs`、把体注册进 keyed 坑位 `sidebar.right.pane.tab`(键 = 定义的 `id`),因此类型的寿命恰等于其插件。引导页与文件树是按 kind 打开的页类型;文档预览是以最低档认领 Session 作用域 `file` 资源地址的查看器。类型的控件住在自己的体里;pane 的 tab 条只承载面板自身的动作。文案由各包的命名空间(`sidebarRight`、`sidebarDocumentPreview`、`sidebarFiles`)以 locale 方式持有。 ### 引导页 +[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代本节的默认引导选择;引导页注册、替换和唯一性保持不变。 + 引导页是 pane 承载内容之前显示的东西。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }`,没有 `patterns`:引导页不查看任何东西,所以经 `openTab` 按 kind 打开,并记在页地址 `sidebar://guide` 之下——那是注册表自己的记账,调用方从不拼它。tab 标题是 `开始` / `Start`,在 pane 播种时捕获进布局记录,于是之后切换语言只重标类型,不改已开着的 tab。 体是一根居中的列——一句引导语(`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`)、一行文案(`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`),以及一组最宽 480px 的入口框栅格,每框至少 160px,能放几列放几列。入口框按 `order` 从每个已注册类型的 `guide[]` 投影而来,经注册表可观察的 `guide()` 列表,因此后注册的类型不用引导页知道就能出现。一个框显示贡献类型的图标、标题与说明;点选它调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。 @@ -26,21 +28,23 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 ### 文本预览 -`text` 是每个文件的兜底查看器。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-textpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', title: basenameOf }`。pattern 含 `:`,因此匹配整个地址;`fallback` 是最低档,所以 `extension` 或 `builtin` 档上一个 pattern 更窄的类型(比如 `*.png`)接走那些地址,其余一切落到这里,而 text 类型对任何文件都留在候选列表中。`id` 是包名,兼作体坑位的 `key`,于是一个接管了 `text` kind 的扩展不可能让坑位误拿到这个体。标题是地址解码后的最后一段:整个地址仍是内容身份——不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab——只有 chip 上的文字被缩短。 +[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)取代本节的渲染器、加载和资源观察细节。兜底 tab 注册、分页源码导航与正文自有控件仍然有效。 -tab 的地址是 `dsh-resource://file/session//<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<绝对路径>`([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md) 拥有这套语法及 `dsh-util-workspace-path` 里的 `fileAddressFor` / `parseFileAddress` 助手)。预览从不自己拆这个串:`rpc.ts` 里的 `hostFileOf` 调 `parseFileAddress` 得到端点所需的 `{ sessionId, path }`——`session` 地址在它命名的会话下以 Host 解析的相对路径读取,`absolute` 地址在坑位被挂载的会话下以绝对路径读取——畸形地址直接抛错,那是程序错误,因为注册表把每个 `file` 地址都路由给这个类型,而造地址的调用方本应使用助手。 +`text` 是 Session 作用域文件的兜底查看器。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', canOpen, title: basenameOf }`。`canOpen` 只接受解析后 scope 为 `session` 的地址。pattern 含 `:`,因此匹配整个地址;`fallback` 是最低档,所以 `extension` 或 `builtin` 档上一个 pattern 更窄的类型(比如 `*.png`)接走那些地址,其余一切落到这里,而 text 类型对任何文件都留在候选列表中。`id` 是包名,兼作体坑位的 `key`,于是一个接管了 `text` kind 的扩展不可能让坑位误拿到这个体。标题是地址解码后的最后一段:整个地址仍是内容身份——不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab——只有 chip 上的文字被缩短。 -元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——从 `file` 提供者得到 `{ absolutePath, version, bytes, changed }`;体读 `changed` 与资源的失败态。内容是类型自己的事,经 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` 一次读一页行,不传 `limit`,因此页长就是 Host 配置的上限(`maxLines`,默认 5000 行;且一页不得超过 `maxBytes`,默认 2 MB)。首次挂载读第 1 页;已加载文本末尾的 **加载更多** 按钮读下一页直到 `eof`,读取进行中它禁用并显示 `正在读取…` / `Reading…`,文件读完或某页失败后消失。页按文件顺序追加,没有分隔也没有行号,每页带着自己的行数(`lines`),单个空行与越过文件末尾的页由此区分。来自更新文件版本的第一页替换旧版本的页;更新版本的后续页不被采用,从第一页重新走一遍,于是体永不同时显示两个版本。face 按 tab 记请求代次:重载递增它,旧代次结算的页什么也不写。切走再切回的 tab 什么都不读,因为页住在 store 里而不是体里。 +tab 使用 `dsh-resource://file/session//`,其中路径可以是相对路径或绝对路径([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md)负责该语法与 `fileAddressFor` / `parseFileAddress` 辅助函数)。`hostFileOf` 只接受这种 Session scope,并从地址取得 Session 与路径;不认领不带 Session 的 `absolute` 地址。被认领的地址若格式错误,则作为程序错误抛出。 + +元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——产生 `WorkspaceFileStat`;正文把其观察版本与已加载内容版本比较。Preview face 通过 `remote.workspaceFiles.read` 读取文本,通过 `readAll` 读取完整字节。后续文本页若来自更新版本,则从第一页重新开始;被重载或 tab 销毁淘汰的请求不能再写入。[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)负责各渲染器的加载方式。 store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持有 `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`。按 tab 而非按文件分桶是有意的——同一文件的两个 tab 各自滚动。face(`loadPage`、`reloadPages`)是唯一的异步半边:它标记读取进行中,等待 Remote 结果,再经 store 的 action 写入一页或一次失败;若 owner 的 `signal` 已触发则什么也不写。`signal` 同时终结这个桶:face 在 tab 首次读取时挂一个 abort 监听器,由它忘掉桶——不是体,体随 tab 切换反复挂载卸载;从未读过的 tab 没有桶也没有监听器,而 tab 记录可能在其体被另一 tab 挡住而卸载时结束。因此滚动位置、换行与已答过的导航都活得比体久:tab 回来时停在读者离开的地方,而不是重读或再跳一次。刷新页面后什么都不保留。 导航是一个 `line`。`read` 工具行把它 1 起的 `offset` 以 `openResource(address, { params: { line } })` 传来,产物 chip 什么都不传;体把 `navigation.params` 收窄为 `SidebarRightResourceParamsMap['file']`(`{ line?: number }`,由 `file` 类型的拥有者声明),不做运行时校验,因为调用方与体相遇在同进程的类型化边界上。已加载的页够不到该行时,体读下一页,再读,直到覆盖它或文件结束——页按顺序加载,没有 seek——然后把该行滚到体顶部并高亮,每个 `navigation.revision` 一次。store 记下已答过的 revision,于是同一 revision 下重新挂载的体恢复滚动位置而不再跳;对同一文件再次 `openResource`(聚焦而非复制)以新 revision 到来并再跳一次。超出文件末尾的行在 `eof` 处静默停下;补页途中失败的页终止补页并显示失败行。 -文件变了只提示,不应用。当 `file` 资源报告 `changed`——agent 在上次 `stat` 之后经工具写了该文件——路径行上方出现一条提示 `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.`,带一个 `重新载入` / `Reload` 按钮。只有点击才同时做两件事:`meta.reload()`(重新 `stat`,清掉 `changed`)与 `reloadPages`(丢掉所有页,重读第 1 页)。滚动位置保留,读者停在原处。没有别的东西触发重载:树和预览都不监听文件系统,外部编辑不会被提示。资源变为 `failed`——文件被删,或 Host 拒绝——时,同一位置出现一条失败条,句子来自 `failure-line.ts`,带同一个重新载入按钮,并优先于尚未处理的 `changed`;已读的页留在它下方。 +文件变了只提示,不应用。正文把已加载版本及读取开始时捕获的观察版本与后续 `WorkspaceFileStat.version` 比较;不同则显示变更提示。重新载入只通过 Preview face 重读当前 tab,不修改共享资源元数据或其他 tab。资源失败占用同一个提示位置,已加载内容仍保留在下方。 -体的头部是一行:左边是地址所命名的文件路径(12px、三级色、单行、溢出省略号、悬停显示完整路径),右端是两个 24px 控件——换行开关(`自动换行` / `Wrap lines`,显示按下态,**默认开**、按 tab 记:长行折行、绝不横向滚动,直到读者关掉它,此后文件体自己横向滚动)与一个重新读取按钮(`重新读取文件` / `Read the file again`),做的恰是变更提示条按钮做的事。两个控件都永不禁用。预览占满 pane 体的全部高度(对 pane 体取 `height: 100%`;pane 体是高度确定的块级滚动容器),于是短文件下方不留另一块样式不同的空白,而文件体——等宽、13px、行高 1.6、上下 10px 内边距——是唯一的滚动者:长文件在头部与变更提示条之下滚动,二者不动。 +正文头部为一行:左侧显示完整文件路径,右侧放匹配渲染器菜单、按条件出现的换行开关和重新载入按钮。[Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md)负责当前控件与渲染器行为。预览占满 pane 正文的全部高度,其文档正文是固定头部与变更提示条下方的滚动区域。 -某页失败时,已显示的页保留,并在已加载文本末尾加一句以文件而非传输为主语的说明,带一个重读同一页的 `重试` / `Retry` 按钮:`workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`;`workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`;`workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.`,字节上限渲染为 `2 MB` 这样的形式;`workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`;`workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`;其余任何失败,无论载体层还是未分类,`读取失败:{message}` / `Read failed: {message}` 并带上失败自身的消息。映射住在 `failure-line.ts` 里,与组件分开以便单独测试;读者未命名的错误码落到带传输层消息的通用句。目录或二进制文件因此只显示一行失败说明;空文件显示头部与一个空的体,没有任何标记。 +读取失败时保留已显示的内容,并增加本地化失败说明与重试操作。Preview 为可处理的文件错误提供专用文案,其他代码使用载体消息兜底;`outside-workspace` 属于目录列举,不是 Preview 专用失败。 ### 文件树 @@ -94,7 +98,7 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态 ## Consequences -- `ui-sidebar-right` 之外写的类型有了一份完整样板:`ui-sidebar-textpreview` 演示一个查看器——由地址推出的读取、按 tab 分桶的独占 Slot store、inject face、类型化的导航参数与体内自有控件;`ui-sidebar-files` 演示一个带引导入口、懒填充 store 的页类型;引导页演示一个链 fallback。 +- `ui-sidebar-right` 之外写的类型有了一份完整样板:`ui-sidebar-documentpreview` 演示一个查看器——由地址推出的读取、按 tab 分桶的独占 Slot store、inject face、类型化的导航参数与体内自有控件;`ui-sidebar-files` 演示一个带引导入口、懒填充 store 的页类型;引导页演示一个链 fallback。 - 按页读取让每次请求都有界(`maxLines` 行、`maxBytes` 字节),代价是一个 **加载更多** 控件、没有总行数,以及到深处某行的顺序补页;导航到一个大文件的第 40,000 行要先读八页。 - 只提示不应用,让读者在 agent 反复写入期间保住位置,代价是点击之前显示的是旧文本;外部编辑永不提示。 - 重新载入只读第 1 页,所以身在文件深处的读者重载后回到文件开头再往后翻;滚动位置保留但可能指向已加载文本之外。 @@ -109,10 +113,9 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态 ## Deferred - 虚拟化或可 seek 的分页加载(页按顺序加载)、恢复已加载范围的重新载入、节流的滚动位置持久化,以及 `ui-primitives` 里的换行图标。 -- 文本预览的行号、语法高亮、Markdown 渲染、图片与搜索;总行数或文件末尾标记。 +- 图片、搜索、总行数与文件末尾标记。 - 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。 - 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。 -- `ui-sidebar-textpreview` 与 `ui-sidebar-files` 的中文 README 对照。 ## Related diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml new file mode 100644 index 0000000000..27af3c5438 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +2026-09-08-present-workspace-source-files.md: 6239c9bd920849f3c8cf4fdee2e1ded4b758b01d +2026-09-08-present-workspace-source-files.zh.md: 7aa5bebc97558b9b0cb74406303d038483c39d94 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md new file mode 100644 index 0000000000..6239c9bd92 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -0,0 +1,37 @@ +# Agent Note: Present declares workspace source files + +Status: implemented + +English | [中文](2026-09-08-present-workspace-source-files.zh.md) + +## Problem + +Users need to open and edit the files produced in their workspace, including shell-created files that have no editor mutation records. Preserving an independent delivered version adds content storage, copy verification, temporary-file retention, and a second editing destination to this workflow. + +## Decision + +The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. + +The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. + +An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The existing produced-file row retains its separate text-preview behavior. + +## Alternatives considered + +**Immutable attachment snapshots and editable temporary copies** preserve delivered versions after source edits or deletion, but make desktop edits diverge from workspace files and introduce retention work without a current product requirement. This decision supersedes the [snapshot-delivery design](../../archived/feature/2026-09-08-web-explicit-file-delivery.md). Neither a download endpoint nor a fallback copy remains; both require an explicit future product decision. + +**Opening attachment-store files directly** lets editors mutate immutable objects. A future persistent delivery system needs an owned editing and retention policy, such as copy-on-write, before exposing saved versions to applications. + +**Generic artifact fields or a Host tool subpath inside the UI package** broaden unrelated APIs or couple preset installation to browser packaging. A tool-owned event and ordinary package preserve existing extension points and publication rules. + +**Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text. + +**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees. + +## Consequences + +The Session log persists declarations but no attachment references or file contents from `present`. Session ZIP exports contain these declarations; transferring the log does not transfer workspace files. The event remains required-on-read because silently losing delivery declarations would alter reconstructed or forked history. Released Session format generations remain unchanged. + +The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names, types, and descriptions without stale byte-size metadata. No artifact service or speculative storage fallback is introduced. + +Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md new file mode 100644 index 0000000000..7aa5bebc97 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -0,0 +1,37 @@ +# Agent Note:Present 声明交付工作区源文件 + +Status: implemented + +[English](2026-09-08-present-workspace-source-files.md) | 中文 + +## 问题 + +用户需要打开并编辑工作区中产出的文件,包括没有编辑器修改记录的 shell 产出文件。保存独立交付版本会为这一流程增加内容存储、副本校验、临时文件保留,以及第二个编辑目标。 + +## 决策 + +[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 + +工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 + +经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为。 + +## 考虑过的替代方案 + +**不可变附件快照和可编辑临时副本**可在源文件编辑或删除后保留交付版本,但会使桌面编辑与工作区文件分离,并在缺少当前产品需求时引入保留工作。本决策取代[快照交付设计](../../archived/feature/2026-09-08-web-explicit-file-delivery.md)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。 + +**直接打开附件存储文件**会让编辑器修改不可变对象。未来持久化交付系统需要先明确编辑和保留策略,例如写时复制,再将保存版本暴露给应用。 + +**通用 artifact 字段或 UI 包内的 Host 工具子路径**会扩展无关 API,或将 preset 安装与浏览器打包耦合。工具拥有的事件与普通包保留现有扩展点和发布规则。 + +**以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。 + +**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。 + +## 影响 + +Session 日志持久化声明,不保存来自 `present` 的附件引用或文件内容。Session ZIP 导出包含这些声明;转移日志不会转移工作区文件。该事件仍要求读取端识别,因为静默丢失交付声明会改变重建或 fork 的历史。已发布 Session 格式代际保持不变。 + +仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称、类型和说明,不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。 + +定向测试覆盖不读取内容的声明、无效输入、被阻止的结果、源路径身份、编辑后的当前字节、缺失文件、工作区越界、fork 相对路径、重试、取消与释放。录制的 Web 场景覆盖嵌套成功后外层失败、源文件编辑、重新加载、删除错误、卡片与正文打开且无浏览器下载,以及不包含交付内容的 Session 导出。 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml new file mode 100644 index 0000000000..2d8d26b371 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md +2026-09-08-sidebar-default-pages.md: c78d6a3af2c88c61ee5ba8fe4319d9b3f59ae7b4 +2026-09-08-sidebar-default-pages.zh.md: 8e0a081cb25862bffc20fdcac2b97c57b0080c36 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md new file mode 100644 index 0000000000..c78d6a3af2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md @@ -0,0 +1,27 @@ +# Agent Note: Sidebar default pages and close protection + +Status: implemented + +English | [中文](2026-09-08-sidebar-default-pages.zh.md) + +## Problem + +A guide with one registered entry adds a click without offering a choice. Hiding only the last tab's close button would let an added guide make the default file browser closable again. + +## Decision + +The Sidebar selects each default page from the registered guide-entry list. Exactly one entry opens that entry's page; zero or multiple entries open the guide. Resource viewers without guide entries do not affect this count. Explicitly adding a guide always opens a guide, and each pane holds at most one. + +A single-entry default is protected from explicit close for its record lifetime. Every pane's final tab is also protected; other tabs can close. The Sidebar stores protected record IDs and shares one close predicate between its store actions and docking controls. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only. + +This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-text-preview-and-file-tree.md) and explicit last-tab closing in [docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, content-state, engine and layout ownership decisions remain active. + +## Alternatives considered + +**Count all registered tab types or currently open tabs.** Neither counts choices available on the guide; resource viewers need not contribute an entry. + +**Protect only the final tab.** Adding a guide would expose a close control on the single-entry default, violating its retained-entry behavior. + +## Consequences + +One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab, while ordinary close cannot empty a pane. Store and component tests cover registration counts and close protection; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing the guide. diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md new file mode 100644 index 0000000000..8e0a081cb2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Sidebar 默认页与关闭保护 + +Status: implemented + +[English](2026-09-08-sidebar-default-pages.md) | 中文 + +## 问题 + +只有一个注册入口的引导页增加一次点击,却不提供选择。若只隐藏最后一个 tab 的关闭按钮,新增引导页后,默认文件浏览页又会变得可关闭。 + +## 决策 + +Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入口时打开对应页面;没有入口或有多个入口时打开引导页。没有引导入口的资源查看器不影响计数。显式添加引导页始终打开引导,每个格最多持有一个。 + +单入口默认页在记录生命周期内受到显式关闭保护。每个格的最后一个 tab 也受保护;其他 tab 可以关闭。Sidebar 保存受保护的记录 ID,store 动作与停靠控件共享一个关闭判定。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。 + +本决策取代[随包类型](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的默认引导选择,以及[停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的显式关闭最后一个 tab。它们的注册、内容状态、引擎与布局所有权决策继续有效。 + +## 考虑过的替代方案 + +**统计所有已注册 tab 类型或已打开的 tab。** 两者都不代表引导页提供的选择;资源查看器不一定贡献入口。 + +**只保护最后一个 tab。** 新增引导页后,单入口默认页会出现关闭控件,违反保留该入口的行为要求。 + +## 后果 + +单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab,普通关闭则不能清空一个格。store 与组件测试覆盖注册数量和关闭保护;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭引导后返回 Files。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 91c1bc8481..95ff31f9f1 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: b81f386f0d0e820b0361c775fb4c45a9e633d04b -2026-07-30-generated-third-party-notices.zh.md: d3b3e1686bfdf8901902f995205c1dbdd6dfbf38 +2026-07-30-generated-third-party-notices.md: 41ac0c75ca55c81f2055c867bd029ee7e4a350f9 +2026-07-30-generated-third-party-notices.zh.md: 4097831828287e7180cf37d713fa4694ae028b31 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index b81f386f0d..41ac0c75ca 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -20,7 +20,7 @@ One trigger gap is accepted rather than worked around: lefthook inspects only fi The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail. -**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the root source-run scripts execute through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). +**Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook. The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. Source execution can mount any plugin package from a user's `cordis.yml`; `@modelcontextprotocol/sdk` and the OpenTelemetry packages therefore reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index d3b3e1686b..4097831828 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -20,7 +20,7 @@ Status: implemented 文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。 -**分层依据是声明方所在区域,而非 manifest 字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一 manifest——即根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而根目录的源码运行脚本通过 `tsx` 执行,根本没有任何 manifest 把它声明为运行时依赖,只能由生成器显式标记。 +**分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行。 运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。从源码运行时,用户可以通过 `cordis.yml` 挂载任何插件包;因此,`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index ff4e50c508..56858d05b2 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: ba63af5a8f1d32035e116b3900eb9d5905f326d0 -2026-08-08-native-windows-pull-request-ci.zh.md: 3a0c8f510f2f8881833633f69d8ac5d7330d5195 +2026-08-08-native-windows-pull-request-ci.md: 690f8e6f9b13fa7e72240a42ff482bd83f9088b0 +2026-08-08-native-windows-pull-request-ci.zh.md: 9efa3cbcf33b6c12e4eed253b6a0546c79b768fe diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index ba63af5a8f..690f8e6f9b 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -32,7 +32,7 @@ Windows durable JSONL paths keep drive roots in native spelling and apply the ex Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. -Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.149.1 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. +Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.153.4 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 3a0c8f510f..9efa3cbcf3 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -32,7 +32,7 @@ Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并 启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 -Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.149.1 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 +Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.153.4 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 96e9ba396f..692a6fe140 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: 6729b506a9cfe7f6d4410dbf0750621901e07293 -2026-08-10-npm-release-sequences.zh.md: 18a30d6dd57c17b8e13cc3a1f71bc91242a391ff +2026-08-10-npm-release-sequences.md: c039db2c7463f93f6f8847ae0ae6100df650f2a5 +2026-08-10-npm-release-sequences.zh.md: 1f14c03beb57d3a574305b348379b1542836083c diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index 6729b506a9..c039db2c74 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -8,7 +8,7 @@ English | [中文](2026-08-10-npm-release-sequences.zh.md) This repository held three unrelated groups of publishable packages and no channel that sent any of them to a registry. -`packages/*/*` and `apps/*` form the runtime surface of `@deepseek-ai/dsh`; `vendor/*` holds nine rescoped Cordis framework packages, each carrying its upstream version; `native/landlock-run/packages/*` holds Linux platform packages with their own workflow. The three differ in version baseline, change rate, and build requirements: dsh moves with the product, vendor moves only when upstream is re-synced or a local modification changes, and native needs a musl toolchain and one build per architecture. Forcing them through one pipeline means every product release republishes the framework and the native binaries. +`packages/*/*` and `apps/*` form the runtime surface of `@deepseek-ai/dsh`; `vendor/*` holds nine rescoped Cordis framework packages, each carrying its upstream version; `native/system/packages/*` holds Linux platform packages with their own workflow. The three differ in version baseline, change rate, and build requirements: dsh moves with the product, vendor moves only when upstream is re-synced or a local modification changes, and native needs a musl toolchain and one build per architecture. Forcing them through one pipeline means every product release republishes the framework and the native binaries. Two hard blockers sat in the way. All 217 workspace manifests set `private: true`, which npm refuses to publish. The subtler one was 933 hand-written `peerDependencies: "^0.0.1"` entries between sibling dsh packages: `pnpm pack` substitutes the `workspace:` protocol but leaves semver ranges alone, and `^0.0.1` means `>=0.0.1 <0.0.2` — it excludes `0.0.2`, and semver excludes prereleases from a range without a prerelease of its own, so it excluded `0.0.1-rc.1` too. Those entries never failed only because the version never left `0.0.1`. @@ -24,7 +24,7 @@ Two hard blockers sat in the way. All 217 workspace manifests set `private: true |---|---|---|---|---| | dsh | Publish set: non-experimental `packages/*/*` + `apps/*`; private experimental packages join only the shared version bump | one version for the publish set, private dsh packages, and workspace root, `0.0.x` | `dsh-v` | `release.yml` (pack) / `release-publish.yml` (publish) | | vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor--v` (one per package) | `release-vendor.yml` (pack) / `release-vendor-publish.yml` (publish) | -| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v` | `landlock-run-release.yml` | +| native | `native/system/packages/*` | its own `0.0.x` | `node-addon-system-v` | `node-addon-system-release.yml` | All three publish to the `@deepseek-ai` scope on npmjs.com, and access is per sequence rather than per scope: the vendored framework and the native packages are `public`, and the dsh family has been `public` since its own sequence went public on 2026-08-13 ([rationale](../../archived/process/2026-08-13-public-vendor-and-native-sequences.md)). No publish path passes `--access`, because one flag cannot serve sequences that disagree and would override the manifest that owns the level. @@ -123,6 +123,8 @@ A dsh verification installs the vendored family's pack output too. The harness p The verification also packs the Landlock entry, which `dsh-sandbox-local` declares as a plain dependency, and omits optional dependencies. The platform packages behind those optional entries need a musl toolchain and one build per architecture, so a job on one runner cannot produce them; a consumer that cannot install them must still start, which is what optional means here. The verification therefore reads a directory by its contents rather than a pack order, because a directory can hold tarballs packed only to satisfy a cross-sequence dependency. +The installed-consumer probe captures npm's HTTP diagnostics and includes them when installation fails. Registry response codes and cache status remain visible even when npm reports a failed peer manifest fetch as `ERESOLVE` with an undefined version. + ### Repository changes this carried | Item | Content | diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index 18a30d6dd5..1f14c03beb 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -8,7 +8,7 @@ Status: implemented 这个仓库有三组互不相干的可发布包,却没有任何发布通道把它们送上 registry。 -`packages/*/*` 与 `apps/*` 组成 `@deepseek-ai/dsh` 的运行面;`vendor/*` 是九个 rescope 过的 Cordis 框架包,各自带着上游的版本号;`native/landlock-run/packages/*` 是 Linux 平台包,有自己的 workflow。三组的版本基线、变更节奏和构建要求都不同:dsh 随产品迭代,vendor 只在同步上游或改动本地修改时才动,native 需要 musl 工具链和逐架构构建。把它们塞进一条发布流水线,等于每次产品发版都要重发框架和原生二进制。 +`packages/*/*` 与 `apps/*` 组成 `@deepseek-ai/dsh` 的运行面;`vendor/*` 是九个 rescope 过的 Cordis 框架包,各自带着上游的版本号;`native/system/packages/*` 是 Linux 平台包,有自己的 workflow。三组的版本基线、变更节奏和构建要求都不同:dsh 随产品迭代,vendor 只在同步上游或改动本地修改时才动,native 需要 musl 工具链和逐架构构建。把它们塞进一条发布流水线,等于每次产品发版都要重发框架和原生二进制。 挡路的还有两处硬门。全部 217 个 workspace manifest 都是 `private: true`,`npm publish` 直接拒绝。更隐蔽的是 933 条 dsh 兄弟包之间硬写的 `peerDependencies: "^0.0.1"`:`pnpm pack` 只替换 `workspace:` 协议,不动语义范围,而 `^0.0.1` 等于 `>=0.0.1 <0.0.2`——发 `0.0.2` 落不进去,发 `0.0.1-rc.1` 也落不进去(semver 规定不带预发布段的范围排除预发布版本)。这些条目至今没出事,只因为版本一直停在 `0.0.1`。 @@ -24,7 +24,7 @@ Status: implemented |---|---|---|---|---| | dsh | 发布集:非 experimental 的 `packages/*/*` + `apps/*`;私有实验性包仅加入共享版本 bump | 发布集、私有 dsh 包与 workspace 根共用一个 `0.0.x` | `dsh-v<版本>` | `release.yml`(pack)/ `release-publish.yml`(发布) | | vendored framework | `vendor/*` 九个包 | 每包各自一条版本线 | `vendor-<包名>-v<版本>`(每包一个) | `release-vendor.yml`(pack)/ `release-vendor-publish.yml`(发布) | -| native | `native/landlock-run/packages/*` | 自己的 `0.0.x` | `landlock-run-v<版本>` | `landlock-run-release.yml` | +| native | `native/system/packages/*` | 自己的 `0.0.x` | `node-addon-system-v<版本>` | `node-addon-system-release.yml` | 三组一律发到 npmjs.com 的 `@deepseek-ai` scope,且 access 按序列而非按 scope 区分:vendored 框架与 native 包是 `public`,dsh 族自 2026-08-13 其自身序列公开发布起即为 `public`([理由](../../archived/process/2026-08-13-public-vendor-and-native-sequences.md))。没有任何发布路径传 `--access`——一个选项无法服务级别互不相同的序列,且会覆盖真正拥有该级别的 manifest。 @@ -123,6 +123,8 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven 验证还会打一份 Landlock entry 的 tarball——`dsh-sandbox-local` 把它声明为普通 `dependencies`——同时略去可选依赖。那些可选项背后的平台包需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起,这正是「可选」在这里的含义。因此验证按目录内容读取 tarball,而不是读发布顺序:一个目录可能只装着为满足跨序列依赖而打出来的包,任何发布顺序都不描述它。 +已安装消费方探针会捕获 npm 的 HTTP 诊断信息,并在安装失败时输出。即使 npm 将 peer manifest 获取失败报告为版本未定义的 `ERESOLVE`,日志中仍能看到 registry 响应码和缓存状态。 + ### 本次带出的仓库改动 | 项 | 内容 | diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index 79c0b25705..5138f59880 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: 8a3eb202255ecbf680dfa0af656fd5b89e958001 -2026-08-18-in-job-partitioned-coverage.zh.md: e7ca2692485fa4f9f0135821963e64347c3cdfdd +2026-08-18-in-job-partitioned-coverage.md: 8ac4635101504419c976a5ab740b691a090e548f +2026-08-18-in-job-partitioned-coverage.zh.md: 55dee82662c7174d661c253463153ca78e3099b2 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index 8a3eb20225..8ac4635101 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -14,6 +14,8 @@ The optimization must retain every test and the merged per-file 100% thresholds. The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`; native Windows now also fixes it at 4 to reduce process-creation pressure under high self-hosted concurrency. No elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](../../archived/process/2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate beside the instrumented work. +The entire `packages/typert/` group is exempt from source coverage and runs in the uninstrumented gate. Its compiler fixtures, catalog reproduction, loader, protocol, and registry assertions remain required. The shared [exempt roster](../../../../scripts/coverage-exempt.ts) selects every Typert package, including nested tests; both project exclusions and partition inventory consume that roster. Exemption removes coverage collection, not test or hook failures. + When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker. The coordinator collects the instrumented inventory from a `vitest list --filesOnly` run (caller filters narrow it; exempt heavy suites are removed because list does not apply their exclusion), reads recorded per-file durations from a coordinator-persisted gitignored file (restored and saved through the GitHub cache on the windows-coverage job, because checkout removes it and Vitest's own cache never survives CI), and assigns files to partitions by longest-processing-time by way of a min-heap, so the heavy subprocess-bound suites spread across children instead of piling into whichever shard a path hash lands them in. Each partition receives a temporary Vitest config whose include is its file list per project (command-line files exceeded the Windows CreateProcess limit; the mutually exclusive thread-safe and process-bound projects keep only their own files so nothing runs twice), an empty partition is rejected before any child starts, the heaviest partition starts first so its verdict lands earliest (fail-fast), and the duration history is restored and saved through the GitHub cache with per-run keys (cache entries are immutable). Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, and writes one blob report per process. The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index e7ca269248..55dee82662 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -14,6 +14,8 @@ Status: implemented 普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4;原生 Windows 现在也固定为 4,以降低自托管高并发下的进程创建压力。运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](../../archived/process/2026-07-31-coverage-exempt-heavy-suites.md)仍作为独立的无插桩门禁与插桩工作并排运行。 +整个 `packages/typert/` 组豁免源码覆盖率,并在无插桩门禁中运行。其编译器 fixture(测试前置数据)、目录复现、loader、协议及注册表断言仍为必需检查。共享的[豁免清单](../../../../scripts/coverage-exempt.ts)选择每个 Typert 包及其嵌套测试;project 排除规则与分区清单都使用该清单。豁免只移除覆盖率采集,不忽略测试或钩子失败。 + 启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned`。`scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker。协调器通过 `vitest list --filesOnly` 收集插桩清单(调用方过滤器会先收窄清单;exempt 重型套件需在此剔除,因为 list 不应用其排除),从协调器持久化的 gitignore 文件读取逐文件耗时(windows-coverage job 通过 GitHub cache 恢复并保存该文件,因为 checkout 会删除它且 Vitest 自身缓存无法在 CI 存活),并借最小堆按最长处理时间把文件分配到各分区,使重量级子进程密集型套件分散到不同子进程,而不是全部落入路径 hash 恰好命中的那一个分片。每个分区获得一个临时 Vitest 配置,其 include 按 project 拆分(命令行传文件会超过 Windows CreateProcess 上限;互斥的 thread-safe 与 process-bound project 只保留各自的文件,避免任何文件跑两次);空分区会在任何子进程启动前被拒绝,最重的分区最先启动使其结论最早落地(fail-fast),耗时历史通过 GitHub cache 以每 run 唯一键恢复与保存(cache 条目不可变)。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,并让每个进程写出 1 份 blob 报告。 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 @@ -36,7 +38,7 @@ Status: implemented **使用工作流级分片。** 不予采用,因为多个 job 会重复设置工作,并需要上传、下载产物以及合并依赖。所选分区方案只在同一个 job 和工作区内使用多个进程。 -**提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。 +**提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture 不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。 **在每种宿主上使用相同的分区数量。** 先前不予采用,因为 Linux 的 4 进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。本次变更在 Windows 高并发运行暴露 8 分片 worker 启动失败后,将两者统一为 4 分区。 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index d44c9594bb..d24d629c9f 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: 25e9f2ce139a7cd4efb64dbe71d49d8c9f88c24b -2026-08-26-published-dependency-faces.zh.md: ccc198b164b7450b6840862faf23b546c99fa2a6 +2026-08-26-published-dependency-faces.md: 1a94ccedcf6f614c7853c96c329e88d037167460 +2026-08-26-published-dependency-faces.zh.md: 4ac3f461018c21047af6359ca5e27d050da5a2c4 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index 25e9f2ce13..1a94ccedcf 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -28,7 +28,7 @@ A workspace package reached by a runtime value import from the Host entry closur An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each export-table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports covered by neither the package list nor an export table; namespace, dynamic, and side-effect imports remain unbounded unless the complete exact entry is package-classified. -Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Ordinary third-party packages imported by the Host runtime belong in `dependencies`; other third-party relationships keep their declared section. Workspace references use `workspace:^`. +Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, and existing metadata-only peers belong only in `devDependencies`. Host runtime imports, including additional Node entries, follow the Host classification. [Browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) governs ordinary third-party declarations; it partially supersedes their preservation in this decision. Workspace references use `workspace:^`. Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index ccc198b164..4ac3f46101 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -28,7 +28,7 @@ Host 入口闭包中的运行期 value import 所到达的 workspace 包,只 constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。每个导出表的 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝既没有包级分类、也没有导出级分类的导出;除非完整的精确入口已按包分类,否则 namespace、dynamic 和 side-effect import 仍无法限定范围。 -Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时导入的普通第三方包属于 `dependencies`;其他第三方关系保持原区段。Workspace 引用使用 `workspace:^`。 +Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject` 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时 import(包括额外 Node 入口)遵循 Host 分类。[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)规定普通第三方声明,部分取代本决策对它们原区段的保留。Workspace 引用使用 `workspace:^`。 部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。 diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml new file mode 100644 index 0000000000..7fc8e364f2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md +2026-09-08-browser-third-party-build-inputs.md: 97e61f3b99955cb402f9e622b2d40e3ee2953d79 +2026-09-08-browser-third-party-build-inputs.zh.md: e23c4e9d4615c7c5bc788beafcf6b94699f211ce diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md new file mode 100644 index 0000000000..97e61f3b99 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md @@ -0,0 +1,35 @@ +# Agent Note: Browser third-party libraries as build inputs + +Status: implemented + +English | [中文](2026-09-08-browser-third-party-build-inputs.zh.md) + +## Problem + +Prebuilt browser plugins distribute their third-party implementations inside JavaScript, but production npm dependencies still make installers download those libraries separately and resolve their peers. When React is declared only for development, installers can select a different React version for those extra dependencies than the browser artifact uses. Both `use-sync-external-store@1.2.0` and `@tanstack/react-virtual@3.14.9` support the current React 18; this problem does not require a React upgrade. + +## Decision + +Browser-only third-party dependencies belong in `devDependencies`, including implementations inlined into dynamic plugins, static browser-library inputs, and React shared by the Web shell. This partially supersedes the preservation of ordinary third-party declarations in [published dependency faces](2026-08-26-published-dependency-faces.md); that note continues to govern package selection, Host value dependencies, and Cordis identity. + +The dependency classifier collects build inputs from source imports and JSX. Third-party libraries reachable by the Host runtime take precedence as `dependencies`; additional Node build entries must also be checked. Type-only source references do not create Host runtime dependencies. Existing configuration-metadata and shared-Host-export classifications remain unchanged. + +Npm sections do not select browser bundling behavior. Dynamic plugins inline private libraries and obtain React and other shared modules from the platform table; static browser libraries retain bare imports and styles for the final Vite build. Static packages are Web-shell build inputs, not independently installed libraries with every rebundling dependency provided. Source builds need development dependencies; installed published Web artifacts do not. + +License classification follows distributed content. Dependency resolution through the real browser build configurations covers dynamic plugins and the Web shell; resolved third-party implementations remain [runtime disclosures](2026-07-30-generated-third-party-notices.md) even when manifests declare them for development. Test tools, erased type imports, and build tools do not become distributed code merely by appearing in `devDependencies`. + +## Alternatives considered + +**Declare another production React or override peer resolution.** This retains an extra installed graph that the browser does not use, without making that installed copy the browser's shared instance. + +**Inline every static-library dependency early.** This changes Vite's third-party chunks, caching, and CSS handling; dependency classification does not require those build changes. + +**Move every third-party dependency of a Client-bearing package.** Dual-face packages still load Host libraries, including `fflate` for ZIP output and `zod` for RPC validation; those installation relationships must remain. + +**Classify license disclosures directly by manifest section.** Distributing browser code and having an installer download a same-named package are different facts; that distinction cannot remove license checks on distributed code. + +## Consequences + +Production dependencies do not install third-party libraries a second time solely for browser implementations. React and React DOM retain one build version, and plugins consume the Web shell's shared instance. Classification tests constrain browser dev-only inputs, Host precedence, and idempotent repair; publication checks reject React installation leaks, while browser artifact verification independently covers module loading. + +Classification remains source-based. License resolution likewise must not depend on existing `lib/` files or write build outputs; its tests cover real resolution, type erasure, asset references, and missing dependencies. Developers independently consuming static packages or public types supply the corresponding build dependencies themselves; this decision adds no standalone browser-library support promise. diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md new file mode 100644 index 0000000000..e23c4e9d46 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 浏览器第三方库作为构建输入 + +Status: implemented + +[English](2026-09-08-browser-third-party-build-inputs.md) | 中文 + +## 问题 + +预构建浏览器插件的第三方实现已经随 JavaScript 分发,但生产 NPM 依赖仍让安装器另外下载这些库并解析其对等依赖(peer dependency)。React 只在构建期声明时,安装器可以为这些额外依赖选择与浏览器产物不同的 React 版本。`use-sync-external-store@1.2.0` 与 `@tanstack/react-virtual@3.14.9` 都支持当前 React 18;问题不要求升级 React。 + +## 决策 + +浏览器专用第三方依赖属于 `devDependencies`,包括动态插件内联的实现、静态浏览器库的输入,以及由 Web 壳提供共享实例的 React。该规则部分取代[已发布依赖分类](2026-08-26-published-dependency-faces.zh.md)对普通第三方声明的保留策略;该文档继续规定包的选择范围、Host 值依赖与 Cordis 实例身份。 + +依赖分类器从源码导入和 JSX 收集构建输入。Host 运行时可达的第三方库优先归入 `dependencies`;额外 Node 构建入口也必须纳入检查。源码中的纯类型引用不产生 Host 运行时依赖。配置元数据与共享 Host 导出的既有分类保持不变。 + +NPM 字段不决定浏览器打包方式。动态插件继续内联私有库,并从平台模块表读取 React 等共享模块;静态浏览器库继续保留裸导入及样式,由最终 Vite 构建处理。静态包是 Web 壳的构建输入,不承诺独立安装后具备二次打包所需的全部依赖。源码构建需要开发依赖;安装已发布的 Web 产物不需要它们。 + +许可证分类以交付内容为准。真实浏览器构建配置的依赖解析同时覆盖动态插件与 Web 壳;解析到的第三方实现仍计入[运行时披露](2026-07-30-generated-third-party-notices.zh.md),即使清单将其列为开发依赖。测试工具、纯类型导入和构建工具不会仅因处于 `devDependencies` 而算作分发代码。 + +## 考虑过的替代方案 + +**为生产安装额外声明 React,或强制覆盖对等依赖。** 这保留了浏览器不使用的额外安装图,且并未使安装副本成为浏览器共享实例。 + +**把静态库的所有依赖提前内联。** 这会改变 Vite 对第三方分块、缓存和 CSS 的处理;依赖分类不需要改变这些构建行为。 + +**把带 Client 的包的全部第三方依赖移走。** 双面包仍可能在 Host 加载库,例如 ZIP 输出所用的 `fflate` 和 RPC 校验所用的 `zod`;这些安装关系必须保留。 + +**按清单字段直接划分许可证披露。** 浏览器代码的交付与安装器是否下载同名包不是同一事实,不能因此取消对分发代码的许可证检查。 + +## 结果 + +生产依赖不再为纯浏览器实现重复安装第三方库。React 与 React DOM 保持同一构建版本,插件继续消费 Web 壳的共享实例。分类测试同时约束浏览器 dev-only、Host 优先和修复幂等;发布检查拒绝 React 安装泄漏,浏览器产物验证独立覆盖实际模块加载。 + +分类检查保留源码执行方式。许可证解析也不能依赖已有 `lib/` 或写入构建输出;其测试覆盖真实解析、纯类型擦除、资源引用与缺失依赖。独立消费静态包或公开类型的开发者需要自己提供相应构建依赖;本决策不增加独立浏览器库的支持承诺。 diff --git a/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md b/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md index fa767b12bb..b283624f39 100644 --- a/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md +++ b/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md @@ -20,13 +20,15 @@ The scanner fetches every changed-file record before deciding. It fails if the p The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.`, `.corpus.`, `.e2e.`, `.perf.`, `.snapshot.`, `.spec.`, `.stress.`, or `.test.`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced. The [comment-only routing decision](2026-09-08-comment-only-review-routing.md) owns the additional documentation and comment exclusions. -The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it removes the pull-request author and users who remain requested from the matched individual owners, then keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores. +The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, approved owners omitted from new requests, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it fetches the complete chronological review list and reduces each owner's undismissed `APPROVED` and `CHANGES_REQUESTED` reviews to the latest decisive state; `COMMENTED` and `PENDING` reviews leave that state unchanged. It removes the pull-request author, owners with an active approval, and users who remain requested from the matched individual owners. An active approval remains sufficient after later synchronize events, while a later changes-requested review makes the owner eligible again. The review-list operation fails before mutation at 3,000 entries or on an invalid record. + +The workflow keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores. When current review requests exist, the workflow reads the complete review-request timeline before mutation. A current reviewer is workflow-authored only when its latest matching `review_requested` event identifies `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. A non-draft run cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit; current relevance order selects which matching workflow reviewer remains. Planned cancellations release capacity before the workflow selects a new reviewer. A draft run cancels every current workflow-authored request. Requests made by people remain unchanged. An attributable event with invalid provenance and timelines above 3,000 events fail before mutation. ## Verification -[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, changed-LOC aggregation and ranking, complete pagination, the 3,000-file limits, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`. +[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, changed-LOC aggregation and ranking, complete pagination, file and review limits, approval-state reduction, approved-owner suppression and next-owner selection, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`. ## Alternatives considered @@ -40,12 +42,14 @@ When current review requests exist, the workflow reads the complete review-reque **Cancel every reviewer that no longer matches.** A person may request a reviewer for reasons outside the ownership map. Only requests attributed to the workflow identity are safe for automated reconciliation. +**Treat an empty current request as an owner who still needs review.** GitHub removes the pending request when the reviewer submits a review. Requesting an owner with an active approval again adds no ownership coverage and creates repeated notifications after later synchronize events. + **Infer arbitrary semantic source changes from patches or language parsers.** GitHub can omit or truncate patches, and the repository spans many languages. The scanner does not try to prove that two programs behave identically. The later [comment-only routing decision](2026-09-08-comment-only-review-routing.md) adds a narrow lexical comparison only when changed-line counts prove that GitHub supplied the complete patch. ## Consequences Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, rule and changed-file updates remove obsolete workflow-authored requests on the next run, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself. -The workflow requests at most one reviewer per run, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger. +The workflow requests at most one reviewer per run, does not repeat a request while that owner has an active approval, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger. -Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file or timeline API limit fail without applying a partial reviewer mutation. +Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file, review, or timeline API limit fail without applying a partial reviewer mutation. diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml new file mode 100644 index 0000000000..b85c97a4b8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.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/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md +2026-09-03-minimal-profiles-persistent-shell-only.md: d58210cb890e4264b02d591f213045b592ca6bfc +2026-09-03-minimal-profiles-persistent-shell-only.zh.md: 481ef479c2dc01ae503f621df976529aeb6bdbe0 diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md new file mode 100644 index 0000000000..d58210cb89 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md @@ -0,0 +1,35 @@ +# Agent Note: Minimal profiles expose only a persistent shell + +Status: implemented + +English | [中文](2026-09-03-minimal-profiles-persistent-shell-only.zh.md) + +## Problem + +The shipped Web `minimal` preset and standalone `sdk-minimal` profile exposed `str_replace_editor` beside their persistent shell. The editor added a second file-mutation interface and its complete schema to every minimal model request, although the shell already provides file inspection and mutation. It also required a dedicated `fs-local` service that no other row in either minimal composition consumed. + +Using one persistent shell gives the model a consistent file-operation interface and keeps the harness composition aligned with that interface. Leaving the editor mounted but hidden through a presentation filter would preserve an inactive capability that could reappear when presentation configuration changes. + +## Decision + +The shipped minimal compositions expose exactly one platform-selected persistent shell: `bash` on Linux and macOS, or `pwsh` on Windows. Neither composition mounts `@deepseek-ai/dsh-tool-str-replace-editor`, a filesystem tool, or the `fs-local` service that supported the editor. The fixed complete persona, absence of runtime context and compaction, shell timeout, and launch-specific host services remain unchanged. + +The standalone editor package remains available for explicit custom compositions. A trusted user-authored preset or higher profile patch must insert the editor into the Cordis tree with a filesystem provider in the same service scope; the shipped `minimal` and `sdk-minimal` defaults never insert it. The [Python SDK guide](../../../../docs/user/guide/python-sdk.md#opt-in-to-str_replace_editor) provides an executable patch example. + +The shared [persistent Bash consumer](../../../../packages/shell/tool-bash-persistent/README.md#model-experience) uses the one-shot shell's command-status wording while retaining its persistent state. Settled commands append `[Command finished with exit code N]`, including success; timeout output includes `[Command timed out or OOM]` and the shell-reset notice. Trailing newlines are removed before the status trailer. Both minimal Bash descriptions state that network access depends on the task environment. Explicit compositions using this consumer share its output behavior; the persistent PowerShell description and output remain unchanged. + +Exact composition tests assert the single tool and the absence of a preset-local filesystem service. The `sdk-minimal` bundle test and built config dump assert that its row and dependency allowlists contain neither `fs-local` nor `dsh-tool-str-replace-editor`. Web and packaged-Python model-visible snapshots pin the one-tool schema roster. SDK profile smoke tests execute the guide's editor patch and verify file creation and viewing. + +This decision partially supersedes the tool selection in [the bare minimal runtime](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) and the minimal exception in [the base editor decision](2026-09-05-base-default-file-editor.md). Those notes retain authority for prompt ownership, no-compaction behavior, and base-backed file editing. [The application architecture](../../../../docs/architecture.md) owns profile launch and bundle layering. + +## Alternatives considered + +**Keep the editor row and hide its schema.** Rejected because a presentation or restriction layer would leave the capability in the minimal composition and make its absence depend on another setting. + +**Remove the editor package from the distribution.** Rejected because explicit custom compositions remain valid consumers. The requirement concerns the two shipped minimal defaults. + +**Keep the editor only in `sdk-minimal`.** Rejected because the two minimal paths would present different tool contracts to the same model class, and the packaged SDK path would retain the schema cost and unused filesystem service. + +## Consequences + +Minimal agents inspect and modify files through their persistent shell. Their model requests carry one tool schema, and their compositions own no filesystem service. The editor package and explicit editor compositions remain available. Web and SDK replay fixtures pin the persistent Bash status trailers alongside shell state and file effects. diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md new file mode 100644 index 0000000000..481ef479c2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 极简 profile 只提供持久 shell + +Status: implemented + +[English](2026-09-03-minimal-profiles-persistent-shell-only.md) | 中文 + +## 问题 + +随附 Web `minimal` preset 与独立 `sdk-minimal` profile 在持久 shell 之外还提供 `str_replace_editor`。Shell 已经可以检查和修改文件,editor 仍会为每个极简模型请求增加第二种文件修改接口及其完整 schema。它还要求挂载一个专用 `fs-local` 服务,而两份极简组合中的其他配置项都不使用该服务。 + +只使用持久 shell 可以为模型提供一致的文件操作接口,并让 harness 组合与这一接口保持一致。如果保留 editor 的挂载,只通过呈现层过滤隐藏它,那么呈现配置变化时,该能力仍可能重新出现。 + +## 决策 + +随附的极简组合只提供一个按平台选择的持久 shell:Linux 与 macOS 使用 `bash`,Windows 使用 `pwsh`。两份组合都不挂载 `@deepseek-ai/dsh-tool-str-replace-editor`、文件系统工具或支撑 editor 的 `fs-local` 服务。固定的 complete persona、运行时上下文与 compaction 的缺失、shell 超时和各启动路径的宿主服务保持不变。 + +独立 editor 包仍可用于显式自定义组合。受信任的用户自定义 preset 或更高优先级的 profile patch 必须将 editor 插入 Cordis tree,并在同一服务作用域内提供文件系统后端;随附的 `minimal` 与 `sdk-minimal` 默认组合不会插入它。[Python SDK 指南](../../../../docs/user/guide/python-sdk.zh.md#opt-in-to-str_replace_editor)提供可执行的 patch 示例。 + +共享的[持久 Bash 消费方](../../../../packages/shell/tool-bash-persistent/README.zh.md#model-experience)保留跨调用状态,并采用单次 shell 的命令状态文案。完成的命令追加 `[Command finished with exit code N]`,成功时也包含该标记;超时输出包含 `[Command timed out or OOM]` 和 shell 重置说明。追加状态标记前会移除输出末尾的全部换行。两份极简 Bash 描述都说明网络访问取决于任务环境。使用该消费方的显式组合共享相同的输出行为;持久 PowerShell 的描述与输出保持不变。 + +精确组合测试会断言单工具清单以及 preset 内不存在文件系统服务。`sdk-minimal` bundle 测试与构建后配置转储会断言配置项和依赖 allowlist 都不含 `fs-local` 或 `dsh-tool-str-replace-editor`。Web 与打包 Python 的模型可见快照会固定单工具 schema 清单。SDK profile 冒烟测试会执行指南中的 editor patch,并验证文件创建和查看。 + +本决策部分取代[极简裸运行时](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)中的工具选择,以及[base 编辑器决策](2026-09-05-base-default-file-editor.zh.md)中的极简例外。这些 Agent Note 继续负责提示词所有权、无 compaction 行为和基于 base 的文件编辑。[应用架构](../../../../docs/architecture.zh.md)负责 profile 启动与 bundle 分层。 + +## 考虑过的替代方案 + +**保留 editor 配置项并隐藏其 schema。** 不予采用,因为呈现层或限制层会让该能力继续留在极简组合中,并使其缺失依赖另一项设置。 + +**从发行物中删除 editor 包。** 不予采用,因为显式自定义组合仍是有效消费方。本需求只涉及两份随附的极简默认组合。 + +**只在 `sdk-minimal` 中保留 editor。** 不予采用,因为两条极简路径会向同类模型提供不同的工具约定,而且打包 SDK 路径仍会承担 schema 成本和未被其他配置项使用的文件系统服务。 + +## 后果 + +极简 agent 通过持久 shell 检查和修改文件。模型请求只携带一个工具 schema,组合不拥有文件系统服务。editor 包和显式 editor 组合仍然可用。Web 与 SDK 回放快照会同时固定持久 Bash 的状态标记、shell 状态和文件操作结果。 diff --git a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml index 672c293dc5..a4c4a9902a 100644 --- a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md -2026-09-05-base-default-file-editor.md: 68a86769a99d697f9c7c767e9ba59b0cf61669b1 -2026-09-05-base-default-file-editor.zh.md: e8302b609a8647b1a0b90923361da06e859868ab +2026-09-05-base-default-file-editor.md: b87d7986e7beeab30ff5414908239ba95022d231 +2026-09-05-base-default-file-editor.zh.md: db3c70d0a28527391073ddc34a72e9faabe1c3be diff --git a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md index 68a86769a9..b87d7986e7 100644 --- a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md +++ b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md @@ -12,7 +12,7 @@ The shared base selects both `read`/`write`/`edit` and `str_replace_editor`, whi The [base patch](../../../../packages/bundle/base/cordis.patch.yml) selects `read`, `write`, and `edit` for file editing. It does not insert `tool-str-replace-editor`; SDK and Web application patches therefore need no disabling override. The editor package remains available to compositions that insert it explicitly. -[Web minimal](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) inserts its own `str-replace-editor` row in the agent scope. The standalone [sdk-minimal bundle](../../../../packages/bundle/sdk-minimal/cordis.patch.yml) inserts its own row without inheriting base. Both minimal compositions retain their editor. +Web minimal and the standalone `sdk-minimal` bundle own their tool selection independently of base. The [persistent-shell-only decision](2026-09-03-minimal-profiles-persistent-shell-only.md) owns their single-tool defaults. This refines the shared tool defaults in [one dsh launcher](../architecture/2026-08-22-single-dsh-application-launcher.md). That note remains active for launch ownership, shared services, and patch precedence; no active note is fully superseded. @@ -20,7 +20,7 @@ This refines the shared tool defaults in [one dsh launcher](../architecture/2026 **Disable the editor separately in each application.** This leaves overlapping defaults in base and requires each consumer to opt out. The base owns the shared choice directly. -**Delete the tool package or remove it from minimal.** The dedicated minimal compositions use this interface for file operations. Keeping the package and their explicit rows preserves that behavior. +**Delete the tool package.** Explicit custom compositions still use this interface. Base default selection does not remove the package or constrain independently owned minimal defaults. ## Consequences @@ -28,4 +28,4 @@ Base-backed SDK, headless, ACP, and custom profiles omit the editor schema by de ## Verification -The [SDK process tests](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) capture actual model requests for default file tools, explicit editor insertion, and the standalone minimal roster. The [headless process test](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) checks the shared default through its application. [Web minimal snapshots](../../../../apps/web/tests/minimal-preset.snapshot.ts) exercise the editor through the minimal preset. The [headless](../../../../snapshots/session/headless.snapshot.ts), [SDK](../../../../snapshots/sdk/sdk.snapshot.ts), and [ACP](../../../../snapshots/acp/acp.snapshot.ts) recorded sessions pin the assembled model-visible outputs, including the SDK fixture that explicitly inserts the editor. +The [SDK process tests](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) capture actual model requests for default file tools, explicit editor insertion, and the standalone minimal roster. The [headless process test](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) checks the shared default through its application. The [headless](../../../../snapshots/session/headless.snapshot.ts), [SDK](../../../../snapshots/sdk/sdk.snapshot.ts), and [ACP](../../../../snapshots/acp/acp.snapshot.ts) recorded sessions pin the assembled model-visible outputs, including the SDK fixture that explicitly inserts the editor. diff --git a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md index e8302b609a..db3c70d0a2 100644 --- a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md @@ -12,7 +12,7 @@ Status: implemented [base patch](../../../../packages/bundle/base/cordis.patch.yml) 选择 `read`、`write` 和 `edit` 负责文件编辑。它不插入 `tool-str-replace-editor`;因此 SDK 与 Web 应用 patch 无需禁用覆盖。编辑器包仍可供显式插入它的组合使用。 -[Web minimal](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) 在 agent 作用域插入自己的 `str-replace-editor` 配置项。独立的 [sdk-minimal bundle](../../../../packages/bundle/sdk-minimal/cordis.patch.yml) 不继承 base,自行插入配置项。两种极简组合都保留其编辑器。 +Web minimal 与独立 `sdk-minimal` bundle 各自负责工具选择,不依赖 base。[仅持久 shell 决策](2026-09-03-minimal-profiles-persistent-shell-only.zh.md)负责它们的单工具默认值。 本决策细化了[统一 dsh 启动器](../architecture/2026-08-22-single-dsh-application-launcher.zh.md)中的共享工具默认值。该文档对启动所有权、共享服务和 patch 优先级仍然有效;没有被完全取代的活跃 Agent Note。 @@ -20,7 +20,7 @@ Status: implemented **在每个应用中分别禁用编辑器。** 这会在 base 中保留重叠的默认接口,并要求各消费方主动退出。共享选择由 base 直接负责。 -**删除工具包或从 minimal 移除它。** 专用的极简组合通过此接口完成文件操作。保留包及其显式配置项可以保留这一行为。 +**删除工具包。** 显式自定义组合仍使用此接口。base 的默认选择不删除包,也不约束独立负责的极简默认值。 ## Consequences @@ -28,4 +28,4 @@ Status: implemented ## Verification -[SDK 进程测试](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) 捕获默认文件工具、显式插入编辑器与独立极简工具清单的实际模型请求。[headless 进程测试](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) 通过所属应用检查共享默认值。[Web minimal 快照](../../../../apps/web/tests/minimal-preset.snapshot.ts) 通过极简 preset 执行编辑器。[headless](../../../../snapshots/session/headless.snapshot.ts)、[SDK](../../../../snapshots/sdk/sdk.snapshot.ts) 与 [ACP](../../../../snapshots/acp/acp.snapshot.ts) 录制会话固定组装后模型可见的输出,包括显式插入编辑器的 SDK fixture。 +[SDK 进程测试](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) 捕获默认文件工具、显式插入编辑器与独立极简工具清单的实际模型请求。[headless 进程测试](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) 通过所属应用检查共享默认值。[headless](../../../../snapshots/session/headless.snapshot.ts)、[SDK](../../../../snapshots/sdk/sdk.snapshot.ts) 与 [ACP](../../../../snapshots/acp/acp.snapshot.ts) 录制会话固定组装后模型可见的输出,包括显式插入编辑器的 SDK fixture。 diff --git a/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.i18n.yaml new file mode 100644 index 0000000000..3166d9ec81 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.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/simplification/2026-09-07-file-content-scan.md +2026-09-07-file-content-scan.md: 146f41a26b54e2823b7dfbdab5b6738c7a5041da +2026-09-07-file-content-scan.zh.md: 07b4a9ecbca5d63eacccb43a9e3a40d0afe2a4cf diff --git a/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md new file mode 100644 index 0000000000..146f41a26b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md @@ -0,0 +1,32 @@ +# Agent Note: Scan file content without per-array callbacks + +Status: implemented + +English | [中文](2026-09-07-file-content-scan.zh.md) + +## Problem + +Every model dispatch checks complete message content for files, including nested tool results. A request-history CPU profile attributes 23.540 ms of self time to `contentHasFile` and 5.584 ms to its callback. This traversal remains necessary even after [loop-owned freeze provenance](2026-09-06-agent-request-freeze-provenance.md) removes repeated request freezing. The hot LLM source is identical at master `bd5917`, master `112a5`, and the measured `f834b002826453e7918eeb558d052b2c24c56a76`; these observations do not establish PR causality. + +## Decision + +[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) uses direct iteration instead of recursive `Array.some` callbacks. It preserves early exit, nested tool-result traversal, and false results for other block kinds. It stores no identities, validation results, or freeze proofs. Image detection, file projection, request construction, and the 238 ms request-history budget are unchanged. + +## Measurement evidence + +Apple M4 Pro, Node 24.19.0: nine alternating original/candidate pairs run the unchanged [request-history worker](../../../../benchmarks/agent-continuation/agent-continuation.worker.ts) in fresh plain-Node processes. Each process receives a copy of one native-V3 seed. Only the built LLM entry changes; every sample completes 40 requests, zero live tools, and 13,925 events. All totals below are milliseconds, in pair order. + +| Variant | Raw totals | Median | +|---|---|---:| +| Original | 61.772167, 66.480917, 63.590042, 62.250333, 63.804875, 62.504208, 61.887958, 61.062959, 67.120125 | 62.504208 | +| Direct iteration | 53.655375, 56.223416, 57.078125, 54.559250, 54.271459, 55.288917, 55.400416, 56.191916, 54.877041 | 55.288917 | + +The median improves 11.54%; all nine pairs improve, by 4.871–12.243 ms. User CPU medians are 82.019/76.150 ms. A separate five-pair scan probe uses the same synthetic history: 5,601 frozen messages, 13,600 blocks, and 8,801 content arrays scanned 40 times. Original totals are 17.188584, 17.944000, 16.869458, 17.437750, 17.459708; direct iteration totals are 8.726333, 8.373000, 8.851750, 8.479167, 9.074083. These local measurements establish an implementation gain, not a hosted-CI pass or a new calibration. + +## Alternatives considered + +A weak negative-result cache needs proof that every relevant descendant is immutable; a shallow-frozen root is insufficient. Direct iteration provides measured savings without introducing that ownership or invalidation problem. Optimizing image traversal or system-prompt projection lacks evidence from this experiment and is outside this change. + +## Consequences + +The scan remains linear in visited blocks and rereads mutable nested content on each call. [Content tests](../../../../packages/llm/llm/tests/content.spec.ts) cover empty, frozen, nested, and subsequently mutated arrays; service tests preserve file-handle projection, and request-freeze, reconstruction, and resume tests preserve native-history semantics. No model-visible text or Session format changes. The freeze-provenance and [backend-baseline](../testing/2026-09-06-backend-continuation-performance.md) notes retain independent ownership; neither is superseded. diff --git a/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md new file mode 100644 index 0000000000..07b4a9ecbc --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 文件内容扫描不为每个数组创建回调 + +Status: implemented + +[English](2026-09-07-file-content-scan.md) | 中文 + +## Problem + +每次模型分发都检查完整消息内容中的文件,包括嵌套工具结果。请求历史 CPU profile 将 23.540 ms 自身时间归于 `contentHasFile`,将 5.584 ms 归于其回调。即使[循环自有冻结来源证明](2026-09-06-agent-request-freeze-provenance.zh.md)消除了重复请求冻结,这次遍历仍然必需。master `bd5917`、master `112a5` 与实测的 `f834b002826453e7918eeb558d052b2c24c56a76` 的 LLM 热点源码完全相同;这些观察不能证明 PR 因果关系。 + +## Decision + +[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) 使用直接迭代,替代递归的 `Array.some` 回调。它保留提前退出、嵌套工具结果遍历,以及其他块类型返回 false 的行为。它不存储身份、校验结果或冻结证明。图片检测、文件投影、请求构建和 238 ms 请求历史预算保持不变。 + +## Measurement evidence + +Apple M4 Pro、Node 24.19.0:九组交替原始/候选配对在全新的普通 Node 进程中运行不变的[请求历史 worker](../../../../benchmarks/agent-continuation/agent-continuation.worker.ts)。每个进程接收同一个原生 V3 种子的副本。只有构建后的 LLM 入口变化;每个样本均完成 40 次请求、零次实时工具调用与 13,925 个事件。以下全部总耗时单位为毫秒,按配对顺序排列。 + +| 变体 | 原始总耗时 | 中位数 | +|---|---|---:| +| 原始 | 61.772167, 66.480917, 63.590042, 62.250333, 63.804875, 62.504208, 61.887958, 61.062959, 67.120125 | 62.504208 | +| 直接迭代 | 53.655375, 56.223416, 57.078125, 54.559250, 54.271459, 55.288917, 55.400416, 56.191916, 54.877041 | 55.288917 | + +中位数改善 11.54%;全部九组均改善,幅度为 4.871–12.243 ms。用户 CPU 中位数为 82.019/76.150 ms。独立的五组配对扫描探针使用相同的合成历史:5,601 条冻结消息、13,600 个块和 8,801 个内容数组,扫描 40 次。原始总耗时为 17.188584, 17.944000, 16.869458, 17.437750, 17.459708;直接迭代总耗时为 8.726333, 8.373000, 8.851750, 8.479167, 9.074083。这些本地测量证明实现改善,不代表托管 CI 通过或新的校准。 + +## Alternatives considered + +弱引用的否定结果缓存需要证明每个相关后代均不可变;浅冻结根对象并不足够。直接迭代提供实测收益,无需引入此类所有权或失效问题。本实验没有提供优化图片遍历或系统提示词投影的证据,因此它们不在此次变更范围内。 + +## Consequences + +扫描仍与访问块数呈线性关系,每次调用都重新读取可变的嵌套内容。[内容测试](../../../../packages/llm/llm/tests/content.spec.ts)覆盖空数组、冻结数组、嵌套数组与后续变更的数组;服务测试保留文件 handle 投影,请求冻结、重建与恢复测试保留原生历史语义。模型可见文本与 Session 格式均不改变。冻结来源证明与[后端基线](../testing/2026-09-06-backend-continuation-performance.zh.md)记录仍各自拥有独立决策;两者均未被取代。 diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml index 5acf75d598..c2a7bcd232 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md -2026-08-24-session-log-snapshot-corpus.md: bd8941a40bf1c5f0dac15205c5ab0c7d4a3179aa -2026-08-24-session-log-snapshot-corpus.zh.md: d27a3b90e13c73fd46794992d84e6393f0d030ce +2026-08-24-session-log-snapshot-corpus.md: ebcd9709f9cd17ad288d787a13ca66efee5fcc42 +2026-08-24-session-log-snapshot-corpus.zh.md: 8d0614e150c927b491784b51a166e752a7718dae diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md index bd8941a40b..ebcd9709f9 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md @@ -16,7 +16,7 @@ Reserve the top-level `snapshots/` tree and `*.snapshot.ts` suffix for scenarios This decision supersedes the ACP-specific placement and controller ownership in the [record-once/replay-deterministic decision](2026-06-19-acp-snapshot-tests.md), while that note remains authoritative for session-log replay, exceptional overrides, normalization, and ACP transcript comparison. -The recorded session remains the primary input and expected output. Human-originated messages drive the selected public interface, recorded assistant chunks drive deterministic model replay, and the normalized persisted result must equal the fixture. Parent and child sessions share one typed redaction map. Committed fixtures contain relationship-preserving identity tokens and replace request system prompts and tool schemas with tokens; each distinct header class retains one explicit sidecar owner. +The recorded session remains the primary input and, for current-generation scenarios, the expected output. Human-originated messages drive the selected public interface, recorded assistant chunks drive deterministic model replay, and the normalized persisted result must equal the fixture. Parent and child sessions share one typed redaction map. Committed fixtures contain relationship-preserving identity tokens and replace request system prompts and tool schemas with tokens; each distinct header class retains one explicit sidecar owner. Fixture decoding and comparison depend only on the selected JSONL content; filenames identify inventory roles but are not parser inputs. The same strict static catalog validates replay, seed, record, refresh, and normalized comparison paths. @@ -30,6 +30,8 @@ Every existing ACP scenario receives a behavior-preserving destination. Ordinary Workspace inputs remain scenario-local. A mutating scenario compares a complete expected final workspace that record and refresh never rewrite, so a model or tool self-report cannot satisfy the test. Existing intentional session reuse remains an explicit acyclic owner reference; the corpus adds no workspace inheritance or general fixture-merging mechanism. +Current-writer request-header pins are separate from retained migration inputs: `tool-call-turn` pins the default composition, and `empty-response-retry-current` pins the retry composition. Their readable sidecars remain owned by `text-turn`. The six retained historical inputs stay byte-frozen and selected for replay; their pinned directories contain no canonical V3 sibling that could displace them. Separate `writer.expected.jsonl` and `writer..expected.jsonl` files pin exact normalized native V3 parent and child output, while retained SDK scenarios pin current notifications in `notifications.current.expected.jsonl`. These output oracles are not replay generations. The [snapshot kit](../../../../packages/test-support/session-snapshot/README.md) owns selection and refresh behavior. Structural migration can preserve request meaning without reproducing native writer event layout, so the official migration has independent correctness tests. Reverse projection into historical headers, stripping structural differences, skipping output equality, or replacing frozen inputs would conceal regressions instead of verifying those separate obligations. + ## Alternatives considered **Keep ACP as the universal driver.** This preserves the existing harness but continues coupling backend coverage to a low-priority protocol and cannot prove the supported headless, SDK, and Web launch paths. diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md index d27a3b90e1..8d0614e150 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md @@ -16,7 +16,7 @@ Status: implemented 本决策取代[一次录制/确定性回放决策](2026-06-19-acp-snapshot-tests.zh.md)中 ACP 专属的放置位置与控制器所有权;后者继续负责会话日志回放、例外 override、规范化和 ACP transcript 比较。 -录制会话仍是主要输入和预期输出。来自用户的消息驱动所选公开接口,录制的 assistant chunk 驱动确定性模型回放,规范化后的持久化结果必须等于 fixture。父会话和子会话共享同一类型化脱敏映射。提交的 fixture 使用保留关系的身份 token,并将请求 system prompt 和工具 schema 替换为 token;每个不同 header 类仍保留一个显式 sidecar 所有者。 +录制会话仍是主要输入,并在当前代际场景中同时作为预期输出。来自用户的消息驱动所选公开接口,录制的 assistant chunk 驱动确定性模型回放,规范化后的持久化结果必须等于 fixture。父会话和子会话共享同一类型化脱敏映射。提交的 fixture 使用保留关系的身份 token,并将请求 system prompt 和工具 schema 替换为 token;每个不同 header 类仍保留一个显式 sidecar 所有者。 Fixture 解码与比较只取决于选定 JSONL 内容;文件名标识 inventory role,但不是 parser 输入。replay、seed、record、refresh 与规范化比较路径都使用同一个严格静态 catalog 校验。 @@ -30,6 +30,8 @@ Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的 Workspace 输入继续归各场景本地所有。变更文件的场景比较完整的预期最终 workspace,record 与 refresh 绝不改写该预期,因此模型或工具的自报结果无法满足测试。现有的有意会话复用继续使用显式、无环的所有者引用;语料不增加 workspace 继承或通用 fixture 合并机制。 +当前 writer 的 request-header pin 与保留的迁移输入分离:`tool-call-turn` 固定 default 组合,`empty-response-retry-current` 固定 retry 组合。可读 sidecar 仍由 `text-turn` 持有。六份保留的历史输入保持字节冻结,并继续被选为回放输入;其固定历史版本的目录不含会取代它们的规范 V3 同角色文件。单独的 `writer.expected.jsonl` 与 `writer..expected.jsonl` 文件固定精确的规范化原生 V3 父子会话输出,保留历史输入的 SDK 场景则通过 `notifications.current.expected.jsonl` 固定当前通知。这些输出比较基准不是 replay 代际。[快照工具包](../../../../packages/test-support/session-snapshot/README.zh.md)负责选择与刷新行为。结构迁移可以保留请求含义而不复现原生 writer 的事件布局,因此正式迁移拥有独立的正确性测试。反向投影为历史 header、剥除结构差异、跳过输出相等断言或替换冻结输入都会掩盖回归,而不是验证这些相互独立的约定。 + ## Alternatives considered **继续将 ACP 作为通用驱动器。** 这会保留现有 harness,但继续把后端覆盖耦合到低优先级协议,也无法证明受支持的 headless、SDK 和 Web 启动路径。 diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml index bfd2d60086..a1a94872ba 100644 --- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md -2026-09-04-session-open-performance-gate.md: 2937a2aec1dbddb31fde82d2617d69852a611d90 -2026-09-04-session-open-performance-gate.zh.md: b6608ca79d07c3e9fb00d62801038ecefbdf944c +2026-09-04-session-open-performance-gate.md: d69e7424c5b34bc9f50d4a1d355279b65b7dfd8a +2026-09-04-session-open-performance-gate.zh.md: cce4ae3921d70d43dde823d64dca5a0976344100 diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md index 2937a2aec1..d69e7424c5 100644 --- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md +++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md @@ -16,9 +16,9 @@ Linux pull requests run a required `node 24 / benchmarks` job that executes `pnp Required performance gates live under top-level `benchmarks/`, grouped by measured user path rather than package ownership. Host files use `*.bench.ts`, Client-face files use `*.bench.client.ts`, and scenario-specific workers and fixtures stay beside their benchmark without a benchmark suffix. Package-local `.perf.ts` files remain non-gating diagnostics; `scripts/` owns orchestration rather than benchmark cases. -The Session benchmarks synthesize a released-v0 input from fixed parameters: 200 turns with 500 text deltas and 125 reasoning deltas per turn, for 127,400 logical events. The input uses Zstandard with fixed logical-row grouping and frame partitioning, so every run processes the same events, bytes, and frame distribution. The fixture constructs the immutable released-v0 physical rows directly instead of depending on a current-runtime historical encoder; compression and every measured read or migration entry point still use production code. Setup writes the input into a private temporary directory for each sample before timing starts; benchmarks never use recorded Sessions. +The Session benchmarks synthesize a released-v0 input from fixed parameters: 200 turns with 500 text deltas and 125 reasoning deltas per turn, for 127,400 logical events. The input uses Zstandard with fixed logical-row grouping and frame partitioning, so every run processes the same events, bytes, and frame distribution. Each synthetic turn starts its step before appending user surface input, allowing the V2-to-V3 migration to reserve a protected system head without reordering history. The fixture constructs released-v0 physical rows directly instead of depending on a current-runtime historical encoder; compression and every measured read or migration entry point still use production code. Setup writes the input into a private temporary directory for each sample before timing starts; benchmarks never use recorded Sessions. -Every Session endpoint runs at two user-lifecycle points. `first-open` starts with only the released V0 generation and includes migration; read-only consumers do not publish a successor, while writable Agent resume does. Setup produces `post-upgrade-reopen` once through that same production migration outside measurement, then copies both the unchanged V0 predecessor and published V2 successor into each sample root. Reopen samples use a fresh process, so they measure an upgraded user's later disk open without migration or process-local caches. +Every Session endpoint runs at two user-lifecycle points. `first-open` starts with only the released V0 generation and includes migration; read-only consumers do not publish a successor, while writable Agent resume does. Setup produces `post-upgrade-reopen` once through that same production migration outside measurement, then copies both the unchanged V0 predecessor and published current-generation successor into each sample root. Reopen samples use a fresh process, so they measure an upgraded user's later disk open without migration or process-local caches. Each access-kind and endpoint sample runs in a fresh compiled Node child process. Module imports, Host service initialization, and fixture preparation finish before measurement; the measured process performs no extra parse warm-up. Normal-heap mode runs five independent samples, reports every sample plus minimum, median, and maximum, and enforces access-specific fixed budgets against the median. Another child runs the same path under a fixed 128 MB old-space limit and checks only that it completes; extra GC caused by the constrained heap does not enter the normal timing baseline. @@ -35,7 +35,7 @@ The phase profile invokes each layer's production entry point explicitly and doe Normal-heap mode performs a fixed pair of explicit garbage collections after Host initialization and before the cold Session is touched, then records starting memory. It stops operation timing before performing the same garbage-collection sequence while the scenario's intended long-lived objects remain explicitly reachable, then records ending memory. The Agent-resume endpoint retains the Agent, Session, complete events, and normal service caches; its `heapUsed` delta is the primary resident-Session memory budget. Every scenario also reports `external`, `arrayBuffers`, post-GC RSS, and `process.resourceUsage().maxRSS`; the 128 MB mode prevents transient allocation peaks from being hidden by endpoint collection. Explicit garbage-collection time is excluded from operation timing. -The performance gate does not duplicate semantic assertions owned by functional tests; it requires only that the target call completes and reaches its measured endpoint. The Client-fold benchmark continues to use the real `ConversationNodeAssembler` and every Chat Definition, and requires both the large window's absolute time and its scaling relative to the small window to remain below fixed budgets. +A small untimed fixture prerequisite verifies current migration, message preservation, immutable V0 bytes, and successor reopen. Worker failures retain the first and last ten stderr lines, or fatal heap diagnostics, so setup rejection remains distinguishable from a budget breach. The timed performance cases do not duplicate semantic assertions owned by functional tests; it requires only that the target call completes and reaches its measured endpoint. The Client-fold benchmark continues to use the real `ConversationNodeAssembler` and every Chat Definition, and requires both the large window's absolute time and its scaling relative to the small window to remain below fixed budgets. Budgets are calibrated per measured endpoint. Two repeated Node 24.19 x64 CI runs differ by at most 5.2% in their medians; their CPU-heavy wall times are 1.95–2.06× the Node 24.18 arm64 reference run. Except for current-generation `open`, source constants record expected reference-machine durations; `ciTimeBudget()` multiplies them by the measured 2× CI time scale and 1.25× variance headroom. Current-generation `open` uses a directly measured standard-runner expectation of 50 ms with only the 1.25× headroom, rounded up to a 63 ms budget. The retained-heap and Client-fold scaling budgets use only the 1.25× headroom because neither is a wall-clock duration. The 128 MB completion check remains an independent transient-allocation limit. The resulting first-open time limits, constrained-heap checks, and Client-fold limits all reject the known regressions. Pre-stack commit `0d7ea53743e273930a31e9e2b6ca682f21dd4ca5` is the fixed calibration and review reference; CI does not check out or execute the historical repository. Budgets are reviewed source constants and have no environment-variable override. @@ -56,6 +56,8 @@ The pre-stack implementation keeps V0 as its current format, so first open does The [standard two-CPU run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34023970384/job/101461539961) at `ca3ffe95dac2c55eefeb16ed9b61067bbd19ee90` uses Node 24.20.0 x64 and Ubuntu image `20260831.293.1`. Its five current-generation `open` samples are 49.2, 47.4, 49.1, 48.6, and 48.1 ms: median 48.6 ms, maximum 49.2 ms. The rounded 50 ms CI expectation gives a 63 ms limit without reapplying the 2× machine scale. The log identifies two available CPUs but not their model; it does not isolate hardware from the Node-version change. This is endpoint-specific runner calibration, not evidence of an application optimization or a new reference-machine measurement. Every other benchmark passes its existing budget. Deterministic controls reject the observed median at the historical 30 ms limit, accept it at 63 ms, reject a synthetic 75 ms reopen median, and reject a synthetic 4,000 ms first-open duration at its unchanged 550 ms limit. These controls verify budget enforcement, not a measured new regression. +A cold-verifier packaging change removes runtime workspace-module loading without changing these budgets or the measured endpoint. On macOS arm64, Node 24.18.0, the same 127,400-event fixture at `ac48359b195558806ee5a2286697074fd1a52815` takes 164.2, 162.4, 159.7, 149.3, and 167.7 ms for first writable resume (median 162.4 ms). Bundling the verifier through the workspace build gives 119.8, 120.9, 121.9, 122.3, and 121.8 ms (median 121.8 ms, 25% lower). Retained heap stays at 5.4 MB; median peak RSS changes from 144.9 to 143.7 MB. Reopen medians are 27.5 and 27.1 ms, and all 16 Session cases, including the 128 MB completion checks, pass. A CPU profile attributes part of the old verifier cost to module resolution and compilation. The isolated-package built-worker test fails on the original worker because its workspace imports cannot resolve, and passes with the bundled worker, including rejection of an incorrect event count. These local results do not establish Linux runner timing; the existing 450 ms CI gate remains the acceptance check. + The calibrated source budgets are: | Measurement | Reference expectation | CI budget | diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md index b6608ca79d..cce4ae3921 100644 --- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md @@ -16,9 +16,9 @@ Linux pull request 运行必需的 `node 24 / benchmarks` job,执行 `pnpm run 必需性能 gate 位于顶层 `benchmarks/`,按被测用户路径而非 package 归属组织。Host 文件使用 `*.bench.ts`,Client 面文件使用 `*.bench.client.ts`,场景专属 worker 与 fixture 留在对应 benchmark 旁且不带 benchmark 后缀。包内 `.perf.ts` 文件仍是非门禁诊断;`scripts/` 负责编排而不承载 benchmark case。 -Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 500 个 text delta 与 125 个 reasoning delta,共 127,400 个逻辑事件。输入使用 Zstandard,并固定 logical rows 的分组与 frame 拆分,使每次运行处理相同的事件、字节与 frame 分布。fixture 直接构造不可变的 released-v0 physical rows,不依赖当前 runtime 的历史 encoder;压缩以及所有被测读取和 migration 入口仍使用生产代码。输入在计时前写入每个样本独占的临时目录;benchmark 不使用录制的 Session。 +Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 500 个 text delta 与 125 个 reasoning delta,共 127,400 个逻辑事件。输入使用 Zstandard,并固定 logical rows 的分组与 frame 拆分,使每次运行处理相同的事件、字节与 frame 分布。每个合成 turn 先开始 step,再追加 user surface 输入,使 V2-to-V3 migration 能预留受保护的 system head 而不重排历史。fixture 直接构造 released-v0 physical rows,不依赖当前 runtime 的历史 encoder;压缩以及所有被测读取和 migration 入口仍使用生产代码。输入在计时前写入每个样本独占的临时目录;benchmark 不使用录制的 Session。 -每个 Session endpoint 都针对用户生命周期中的两个时点运行。`first-open` 最初只有 released V0 generation,包含 migration;只读消费者不发布后继文件,可写 Agent resume 才会发布。测试准备阶段在计时外通过同一套生产 migration 生成一次 `post-upgrade-reopen`,再把未改动的 V0 前代和已发布的 V2 后继一起复制到每个样本目录。Reopen 样本使用全新进程,因此测量用户升级完成后的磁盘再次打开,不包含 migration 或进程内 cache。 +每个 Session endpoint 都针对用户生命周期中的两个时点运行。`first-open` 最初只有 released V0 generation,包含 migration;只读消费者不发布后继文件,可写 Agent resume 才会发布。测试准备阶段在计时外通过同一套生产 migration 生成一次 `post-upgrade-reopen`,再把未改动的 V0 前代和已发布的当前 generation 后继一起复制到每个样本目录。Reopen 样本使用全新进程,因此测量用户升级完成后的磁盘再次打开,不包含 migration 或进程内 cache。 每个 access kind 与 endpoint 的样本都在全新、已编译的 Node 子进程中运行。模块加载、Host 服务初始化和 fixture 准备在测量开始前完成;测量进程不执行额外的预热解析。正常堆模式运行五个独立样本,报告全部样本及最小值、中位数和最大值,并以中位数执行各访问状态独立的固定预算。另一个子进程使用固定 128 MB old-space 上限运行同一路径,只判断能否完成;低堆限制引起的额外 GC 不进入正常时间基线。 @@ -35,7 +35,7 @@ Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 正常堆模式在 Host 初始化完成且 Session 尚未访问时执行固定的两轮显式 GC,记录起点内存;操作计时结束后,在该场景要求的长期对象仍明确可达时再次执行同样的 GC,再记录终点内存。Agent resume 场景在终点保留 Agent、Session、完整 events 与正常服务 cache,它的 `heapUsed` 增量是常驻 Session 内存预算的主指标。每个场景同时报告 `external`、`arrayBuffers`、GC 后 RSS 和 `process.resourceUsage().maxRSS`;128 MB 模式继续防止瞬时分配峰值被终点 GC 隐藏。显式 GC 时间不计入操作时间。 -性能 gate 不重复功能测试的内容断言,只要求目标调用完成并到达对应的可观察终点。Client fold benchmark 继续使用真实 `ConversationNodeAssembler` 与全部 Chat Definition,要求大窗口的绝对时间和相对小窗口的缩放比均低于固定预算。 +一个不计时的小型 fixture 前置用例验证当前 migration、消息保留、V0 字节不变及后继再次打开。Worker 失败时保留 stderr 首尾各十行或致命堆错误,使准备阶段拒绝与预算超限可区分。计时性能用例不重复功能测试的内容断言,只要求目标调用完成并到达对应的可观察终点。Client fold benchmark 继续使用真实 `ConversationNodeAssembler` 与全部 Chat Definition,要求大窗口的绝对时间和相对小窗口的缩放比均低于固定预算。 预算按各测量终点分别校准。两次 Node 24.19 x64 CI 运行的中位数最大相差 5.2%;其 CPU 密集型壁钟时间是 Node 24.18 arm64 参考运行的 1.95–2.06 倍。除当前 generation `open` 外,源码常量记录参考机器上的预期耗时;`ciTimeBudget()` 将其乘以实测的 2 倍 CI 时间系数和 1.25 倍波动余量。当前 generation `open` 使用标准运行器直接测得的 50 ms 预期值,仅乘以 1.25 倍余量,向上取整得到 63 ms 预算。GC 后增量堆与 Client fold 缩放预算不属于壁钟时间,因此只使用 1.25 倍余量。128 MB 完成性检查仍是独立的瞬时分配限制。由此得到的 first-open 时间上限、受限堆检查与 Client fold 上限都会拒绝已知退化。栈前参考提交固定为 `0d7ea53743e273930a31e9e2b6ca682f21dd4ca5`,只用于校准和评审预算;CI 不 checkout 或执行历史仓库。预算是源码中的受评审常量,不由环境变量覆盖。 @@ -56,6 +56,8 @@ Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 `ca3ffe95dac2c55eefeb16ed9b61067bbd19ee90` 上的[标准双 CPU 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34023970384/job/101461539961)使用 Node 24.20.0 x64 和 Ubuntu 镜像 `20260831.293.1`。当前 generation `open` 的五次样本为 49.2、47.4、49.1、48.6 和 48.1 ms:中位数 48.6 ms,最大值 49.2 ms。取整后的 50 ms CI 预期值给出 63 ms 上限,不重复乘以 2 倍机器系数。日志标明两个可用 CPU,但未记录型号;它无法区分硬件变化与 Node 版本变化的影响。这是端点专属的运行器校准,不是应用优化或参考机器新测量的证据。其他每项 benchmark 均通过既有预算。确定性正反例在历史 30 ms 上限下拒绝实测中位数,在 63 ms 下接受它,拒绝合成的 75 ms reopen 中位数,并以未改变的 550 ms 上限拒绝合成的 4,000 ms 首次打开耗时。这些正反例验证预算执行,不代表测得新的退化。 +一次冷 verifier 打包调整移除了运行时 workspace 模块加载,未改变这些预算或测量终点。在 macOS arm64、Node 24.18.0 上,`ac48359b195558806ee5a2286697074fd1a52815` 对同一份 127,400-event fixture 的首次 writable resume 耗时为 164.2、162.4、159.7、149.3、167.7 ms(中位数 162.4 ms)。通过 workspace build 打包 verifier 后为 119.8、120.9、121.9、122.3、121.8 ms(中位数 121.8 ms,降低 25%)。Retained heap 保持 5.4 MB;peak RSS 中位数从 144.9 变为 143.7 MB。Reopen 中位数为 27.5 和 27.1 ms,包含 128 MB completion check 的全部 16 项 Session 用例通过。CPU profile 将旧 verifier 的部分成本归因于模块解析和编译。隔离 package 的 built-worker 测试在旧 worker 上因无法解析 workspace import 而失败,在打包后的 worker 上通过,同时验证错误的 event count 会被拒绝。这些本地结果不能证明 Linux runner 耗时;现有 450 ms CI gate 仍是验收检查。 + 校准后的源码预算如下: | 测量项 | 参考机预期 | CI 预算 | diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml index dfa20e45f0..ab338ea560 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md -2026-09-06-backend-continuation-performance.md: f4316e790cf62f5027a0f7bfb2d3148cc79e7536 -2026-09-06-backend-continuation-performance.zh.md: 0fb36ba5375c61e907791b31d96f09062b7f62a3 +2026-09-06-backend-continuation-performance.md: 62d8af10015cc2da7b399aae3c9d197f75931753 +2026-09-06-backend-continuation-performance.zh.md: 7c8904fa019b27362e2cdb0e50697921913e5d09 diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md index f4316e790c..62d8af1001 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md @@ -23,6 +23,8 @@ The shared history has 800 completed two-step turns, four tool calls per turn, a The tool execution pipeline, request preparation, Session projections required by those services, persistence, and catalog observations remain production code. Only the model adapter and bounded tool body are synthetic. The adapter retains a request counter, not request objects, so the fixture cannot manufacture a growing retention cost. Sequential input means each idle interval belongs to the one request delivered by this worker; it does not generalize idle to a per-message completion API under concurrent input. +The SDK fixture explicitly inserts `fs-local` and `str_replace_editor` through its profile patch. This preserves the calibrated file-view workload independently of the [minimal profile's shell-only defaults](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md). File reads, timing endpoints, and budgets remain the same. + Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations. The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage. diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md index 0fb36ba537..7c8904fa01 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md @@ -23,6 +23,8 @@ Status: implemented 工具执行管线、请求准备、这些服务所需的 Session 投影、持久化和目录观察均保留生产代码。只有模型适配器和有界工具体是合成的。适配器只保留请求计数,不保留请求对象,因此 fixture(测试前置数据)不会制造不断增长的保留成本。顺序输入使每个空闲区间对应此 worker 提交的唯一请求;这不代表并发输入时可以把空闲状态推广为逐消息完成 API。 +SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor`。这使经校准的文件查看负载不依赖[极简 profile 只提供 shell 的默认组合](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)。文件读取、计时终点和预算保持不变。 + 五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。 父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。 diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml index 1e2848dfde..20015c92fe 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md -2026-09-06-frontend-performance-budgets.md: 7dc7af97d8bb65c17109ab675c250085c9c5831c -2026-09-06-frontend-performance-budgets.zh.md: 9935e382ec4c3c3ede762b23339f14c014b67c4e +2026-09-06-frontend-performance-budgets.md: 4c1a99e10c9b2d7ba7ecbd38f2249ac84e9d330b +2026-09-06-frontend-performance-budgets.zh.md: f563c55e6440de85d71110cbfb2533c7f99218ce diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md index 7dc7af97d8..4c1a99e10c 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md @@ -16,7 +16,7 @@ The existing serial benchmark inventory includes two frontend owners: [active re The browser input contains 240 closed turns, 40 tool results, and 20 code fences, plus mixed-language prose and reasoning. Historical Assistant records carry matching compact streams built through the production accumulator with 12-character reasoning/text deltas and 8-character tool-argument deltas; empty streams would omit stored and transferred payload costs. Nine older-page actions exhaust this input from its observed 25-turn initial window; the readiness probe follows mounted turn growth rather than duplicating the pagination algorithm. Each sample uses a fresh scaffold and browser. Setup, seeding, browser launch, initial shell load, and sidebar expansion are excluded from open timing. Open ends at transcript availability and an editable composer; page and navigation timings end at their target DOM state. Two animation frames include a rendering opportunity, not hardware presentation or a guarantee that every offscreen node painted. -The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Send; typing starts immediately after the first visible marker, without a separate pre-input animation-frame wait. Send lookup stays inside the composer seat; first/final marker lookups stay inside the latest Assistant step and retain visible-state waits. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records click-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak. +The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Enter submission from the focused composer; typing retains focus without a mouse-refocus action or a separate pre-input animation-frame wait. First/final marker lookups stay inside the latest Assistant step and retain visible-state waits. Diagnostics capture reply markers and focus immediately after the first-visible wait, plus browser-clock timestamps and focus at the first actual input event. Replay never waits for input; starvation can still fail overlap. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records Enter-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak. Reconnect uses three fresh compiled plain-Node children. Each creates a 100,000-delta reasoning prefix with distinct timestamps and two compact records before timing `ClientAssistantStream.replace()`. GC precedes the baseline and follows replacement while the result remains reachable; replacement time excludes both collections. The report consumes the result after collection and checks that the next dense live frame remains accepted. This measures reconstruction, not transport, rendering, or an entire reconnect workflow. @@ -66,6 +66,8 @@ A local diagnostic with temporary 3× Chromium CPU throttling reproduces the ove [Run 34036109842, job 101494445658](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34036109842/job/101494445658) records open samples of 875.306861/1083.683529/814.700998 ms, with a median of 875.306861 ms versus 713.909727 ms in the preceding hosted run. The endpoint-specific expectation is 900 ms, rounding up the larger repeated median rather than adding an epsilon to the 875 ms limit; unchanged 1.25× headroom gives 1125 ms. The same enforced assertion accepts the recorded median, rejects it at both historical 500 and 875 ms limits, and rejects a synthetic 1126 ms value at the current limit. All three samples retain trusted input overlap and post-DONE rejection; every other frontend median remains within its unchanged limit. This calibration does not claim a green CI run. +A controlled mouse-refocus delay waits for the real DONE marker without pausing replay: the mouse path rejects a trusted input after DONE, while Enter submission and keyboard-only draft input pass all three samples under the same control. The delay is diagnostic-only. A clean three-sample run on arm64 Node 24.19.0 / Chromium 149.0.7827.55 reports first-reply/input/complete-wall medians of 288.823/418.868/2567.328 ms, with actual overlap and post-DONE rejection in every sample. This proves removal of the mouse-action scheduling dependency, not the cause of a particular hosted stall; all workload constants and budgets remain fixed. + ## Alternatives considered **Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim. diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md index 9935e382ec..f563c55e64 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md @@ -16,7 +16,7 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式 浏览器输入包含 240 个已关闭轮次、40 个工具结果和 20 个代码块,以及混合语言正文和推理。历史 Assistant 记录携带匹配的紧凑 stream,通过生产 accumulator 按 12 字符推理/文本 delta 和 8 字符工具参数 delta 构建;空 stream 会遗漏存储与传输负载成本。从观察到的初始 25 轮窗口开始,九次更早分页操作读完该输入;就绪探针跟踪已挂载轮次增长,不复制分页算法。每个样本使用全新 scaffold 和浏览器。环境准备、数据播种、浏览器启动、初始 shell 加载及侧栏展开不计入打开时间。打开测量在对话可用且输入框可编辑时结束;分页与导航测量在目标 DOM 状态出现时结束。两次动画帧包含一次渲染机会,不代表硬件显示或保证每个屏幕外节点都已绘制。 -续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在发送前安装;首个标记可见后立即开始键入,不单独等待输入前动画帧。发送控件查找限制在 composer seat;首段/最终标记查找限制在最新 Assistant step,并保留可见状态等待。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录点击到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。 +续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在从已聚焦输入框按 Enter 提交前安装;键入保留焦点,不执行鼠标重新聚焦,也不单独等待输入前动画帧。首段/最终标记查找限制在最新 Assistant step,并保留可见状态等待。诊断在首段可见等待后立即记录回复标记和焦点,并记录首个实际输入事件的浏览器时钟时间戳与焦点。重放从不等待输入;响应阻塞仍可能导致重叠失败。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录 Enter 提交到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。 重连使用三个全新编译后的纯 Node 子进程。各进程在计时 `ClientAssistantStream.replace()` 前创建包含不同时间戳、两条紧凑记录和 100,000 个 delta 的推理前缀。在基线前执行 GC,并在结果仍可达时于替换后再次 GC;替换时间不含两次回收。报告在回收后消费结果,并检查下一个稠密序号的实时 frame 仍被接受。这测量重建,不测量传输、渲染或完整重连工作流。 @@ -66,6 +66,8 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式 [运行 34036109842,job 101494445658](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34036109842/job/101494445658) 记录打开样本 875.306861/1083.683529/814.700998 ms,中位数为 875.306861 ms,前一次托管运行的中位数为 713.909727 ms。该终点的预期为 900 ms,向上取整较大的重复测量中位数,而非向 875 ms 上限增加微量余量;不变的 1.25× 余量产生 1125 ms 上限。同一个强制断言接受记录中位数,在历史 500 和 875 ms 上限下均拒绝它,并在当前上限下拒绝合成的 1126 ms 值。三个样本均保留真实输入重叠与 DONE 之后的拒绝;其他所有前端中位数均在不变的上限内。此校准不代表 CI 运行通过。 +受控的鼠标重新聚焦延迟等待真实 DONE 标记,不暂停重放:鼠标路径拒绝 DONE 之后的真实输入,而 Enter 提交与纯键盘草稿输入在相同对照下通过全部三个样本。该延迟仅用于诊断。在 arm64 Node 24.19.0 / Chromium 149.0.7827.55 上,不含延迟的三个样本报告首段回复/输入/完整壁钟中位数 288.823/418.868/2567.328 ms,每个样本均满足实际重叠并拒绝 DONE 之后的输入。这证明移除了鼠标操作调度依赖,并不证明某次托管停顿的原因;全部工作负载常量和预算保持固定。 + ## 考虑过的替代方案 **用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。 diff --git a/.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.i18n.yaml b/.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.i18n.yaml new file mode 100644 index 0000000000..75ddc0220c --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.md +2026-09-07-publint-test-subprocess-lifetime.md: f8b0e0455f44e6cfc7dd1ce120b0edc07e88180a +2026-09-07-publint-test-subprocess-lifetime.zh.md: dd0533752b13f337e5f2453f01ca87f17d6ec3b2 diff --git a/.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.md b/.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.md new file mode 100644 index 0000000000..f8b0e0455f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.md @@ -0,0 +1,25 @@ +# Agent Note: Publint test subprocesses inherit the execution lane deadline + +Status: implemented + +English | [中文](2026-09-07-publint-test-subprocess-lifetime.zh.md) + +## Problem + +The publint script tests have a five-second synchronous subprocess deadline below the Windows coverage lane's existing 90-second test and hook budgets. Captured Windows failures report null status in both the valid and invalid JavaScript/CSS cases; the valid case takes 5028 ms. Those logs omit the subprocess error and signal, so they do not establish ETIMEDOUT. The deadline mismatch is a shared test defect, not evidence that a product change caused the failures. + +## Decision + +The [publint spec](../../../../scripts/publint-all.spec.ts) uses the existing Execa dependency with Vitest's test-context signal. There is no independent subprocess timeout. The [workflow](../../../../.github/workflows/ci.yml) and [coverage argument owner](../../../../scripts/coverage-partitions.ts) remain responsible for budgets. This applies the same lane-ownership rule as the [subagent teardown tests](2026-09-07-subagent-teardown-test-budgets.md) without changing their cleanup. + +Each direct Node child is registered immediately, cancellation requests SIGKILL, and teardown awaits every owned child's result and close event before removing private package roots. Process errors, cancellation, timeout flags, signals, and captured streams are diagnosed before expected exit codes. An ordinary exit code of one remains valid for negative publint cases. All five cases invoke the real script with isolated publication fixtures. + +## Alternatives considered + +- Increase the five-second constant: another local constant would still override the execution lane's budget. +- Preload or replace publint: neither exercises cold script imports and the real publication checks. +- Return after kill: process and pipe closure must precede fixture removal. + +## Consequences + +A readiness-gated deadline regression cancels two live children and checks both closure events, dead PIDs, and informative diagnostics. A missing working directory verifies spawn-error diagnostics. Independent concurrent spec processes exercise temporary-directory isolation and subprocess scheduling. Native Windows CI remains the owner of Windows termination and filesystem evidence; macOS results do not establish those guarantees. Product code, workflow budgets, and snapshot output remain unchanged. diff --git a/.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.zh.md b/.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.zh.md new file mode 100644 index 0000000000..dd0533752b --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-07-publint-test-subprocess-lifetime.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Publint 测试子进程继承执行通道期限 + +Status: implemented + +[English](2026-09-07-publint-test-subprocess-lifetime.md) | 中文 + +## 问题 + +publint 脚本测试自设五秒同步子进程期限,低于 Windows 覆盖率通道现有的 90 秒测试与钩子预算。已捕获的 Windows 失败在有效和无效 JavaScript/CSS 用例中都报告空退出状态;有效用例耗时 5028 毫秒。这些日志没有记录子进程错误和信号,因此不能证明 ETIMEDOUT。期限不匹配是共享测试缺陷,不是产品改动导致失败的证据。 + +## 决策 + +[publint 测试](../../../../scripts/publint-all.spec.ts) 使用现有 Execa 依赖和 Vitest 测试上下文信号,不设独立子进程超时。[工作流](../../../../.github/workflows/ci.yml) 和[覆盖率参数所有者](../../../../scripts/coverage-partitions.ts) 继续负责预算。这与[子代理清理测试](2026-09-07-subagent-teardown-test-budgets.zh.md) 采用相同的通道所有权规则,但不改变其清理逻辑。 + +每个直接 Node 子进程创建后立即登记,取消请求发送 SIGKILL;清理先等待所有已登记子进程的结果与 close 事件,再删除私有包根目录。预期退出码断言之前先诊断进程错误、取消、超时标记、信号及捕获的输出流。正常退出码一仍是 publint 负例的有效结果。五个用例均通过隔离的发布夹具调用真实脚本。 + +## 曾考虑的替代方案 + +- 增大五秒常量:另一个局部常量仍会覆盖执行通道预算。 +- 预加载或替换 publint:两者都不能验证冷启动脚本导入与真实发布检查。 +- 发送终止信号后立即返回:进程与管道关闭必须先于夹具删除。 + +## 后果 + +以就绪状态为前提的期限回归测试取消两个存活子进程,并检查两个关闭事件、PID 已不存在及有用的诊断信息。缺失工作目录验证启动错误诊断。独立并发测试进程验证临时目录隔离和子进程调度。Windows 终止与文件系统证据仍由原生 Windows CI 负责;macOS 结果不能证明这些保证。产品代码、工作流预算和快照输出均不变。 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml new file mode 100644 index 0000000000..e14bbcc662 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md +2026-09-08-ci-readiness-and-completion.md: 01d2d7f91a0ef10e161772d3c398261acc472238 +2026-09-08-ci-readiness-and-completion.zh.md: 584d8ea00c012e197cb75d9fbce03eb2e1fb03a1 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md new file mode 100644 index 0000000000..01d2d7f91a --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md @@ -0,0 +1,37 @@ +# Agent Note: CI assertions wait for owned completion + +Status: implemented + +English | [中文](2026-09-08-ci-readiness-and-completion.zh.md) + +## Problem + +The [empty master PR run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34206953049) fails while waiting one second for webhook Session creation and five seconds for PowerShell output. Neither test measures a startup latency guarantee. A [separate run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34207864157) shows the same short-budget problem in a desktop worker readiness test and captures a feedback acknowledgement while the composer still holds the submitted command. + +Another [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437) reports a null publint child status and an LSP initialization-marker timeout. Their helpers impose five- and three-second limits inside the lane's 90-second test budget. These cases verify publication contents and cancellation behavior rather than cold-start latency. + +The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228) exhausts a one-second registry poll after transport failure. Disconnect cleanup includes cancellation, output draining, persistence, and owner disposal; registry removal alone does not establish complete teardown. + +## Decision + +The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first. + +The [desktop transaction test](../../../../apps/desktop/tests/project-manager.spec.ts) gives the worker readiness marker the active test's execution budget. Its independent `afterEach` releases and awaits workers before deleting private roots, including when the runner abandons a timed-out test body. The poll observes runner cancellation, and teardown reports transaction failures independently from assertion failures. The [PowerShell tests](../../../../packages/shell/pwsh-local/tests/executor.spec.ts) register each helper-created Context before plugin initialization and dispose those Contexts before deleting temporary directories. The background-input case awaits process completion before checking complete output, completed status, and exit code. Consuming reads remain covered by their separate streaming tests. + +The [publint runner tests](../../../../scripts/publint-all.spec.ts) pass the active test budget to their child and check launch errors and termination signals before interpreting its exit code. The [LSP instance test](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts) uses the same budget for its fixture marker, observes the actual pending `didOpen` write before aborting, and captures the query's rejection before waiting for readiness. Its [server fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts) publishes the marker after pausing stdin. Teardown captures the instance list, Context, and directory before its first await. + +The [ACP disconnect tests](../../../../packages/acp/acp/tests/dispose.spec.ts) await the real session handle disposer for both EOF and transport failure. A barrier holds disposal pending while the test checks ownership, then releases it before awaiting completion and checking both registries. Neither case invokes plugin disposal to trigger the behavior under test. The independent teardown hook releases the barrier before disposing the captured Context, including when the test body times out. + +The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) owns lifecycle cleanup budgets. The [persistent PowerShell decision](2026-09-07-pwsh-ci-observable-completion.md) owns exact versus inferred terminal readiness; a one-shot process's completion promise has different semantics. + +## Alternatives considered + +**Larger independent waits.** Rejected where a completion promise already exists. A separate polling deadline continues to compete with the execution lane's budget. + +**Refresh the feedback golden.** Rejected: the populated composer and disabled attachment control describe an in-flight submission. The settled expected UI remains the intended behavior. + +**Serialize CI or retry these tests.** Rejected: neither establishes the missing completion condition or releases a blocked child after assertion failure. + +## Consequences + +Readiness and output assertions preserve their original content and ownership checks. Controlled desktop readiness, webhook preflight, command-response, PowerShell output, publint startup, and LSP initialization delays reproduce the original failures and pass with the completion waits. A stalled desktop-worker control still reports a test timeout while proving that teardown drains the child before removing its directory. The execution lane bounds test bodies and cleanup hooks separately; native Windows execution remains necessary to verify PowerShell and process cleanup there. diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md new file mode 100644 index 0000000000..584d8ea00c --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md @@ -0,0 +1,37 @@ +# Agent Note: CI 断言等待所属操作完成 + +Status: implemented + +[English](2026-09-08-ci-readiness-and-completion.md) | 中文 + +## 问题 + +[master 空 PR 的运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34206953049)在等待 Webhook Session 创建一秒、等待 PowerShell 输出五秒时失败。两个测试都不衡量启动延迟保证。[另一次运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34207864157)在 Desktop worker 就绪测试中暴露了相同的局部短时限问题,并在输入框仍保留已提交命令时截取了反馈确认。 + +另一次 [Windows coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437)报告了 publint 子进程退出状态为 null,以及 LSP 初始化标记等待超时。对应 helper 在通道的 90 秒测试预算内另设五秒和三秒限制。这些用例验证发布内容与取消行为,不衡量冷启动延迟。 + +[ACP coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228)在传输失败后耗尽一秒的注册表轮询期限。断连清理包含取消、输出排空、持久化和 owner 处置;仅从注册表移除不能证明完整拆卸已经结束。 + +## 决策 + +[Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。 + +[Desktop 事务测试](../../../../apps/desktop/tests/project-manager.spec.ts)为 worker 就绪标记使用当前测试的执行预算。独立的 `afterEach` 在删除私有目录前释放并等待 worker,包括运行器放弃超时测试体的情况。轮询观察运行器的取消信号,teardown 独立报告事务失败,不覆盖断言失败。[PowerShell 测试](../../../../packages/shell/pwsh-local/tests/executor.spec.ts)在初始化插件前登记每个 helper 创建的 Context,并在删除临时目录前处置这些 Context。后台输入用例等待进程完成后检查完整输出、完成状态与退出码。消费式读取仍由独立的流式测试覆盖。 + +[publint runner 测试](../../../../scripts/publint-all.spec.ts)将当前测试预算传给子进程,并在解释退出码前检查启动错误和终止信号。[LSP 实例测试](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts)用同一预算等待 fixture 标记,在取消前观察实际尚未完成的 `didOpen` 写入,并在等待就绪前接住查询的 rejection。[服务器 fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts)在暂停 stdin 后发布标记。Teardown 在首次 await 前捕获实例列表、Context 和目录。 + +[ACP 断连测试](../../../../packages/acp/acp/tests/dispose.spec.ts)在 EOF 和传输失败两种情况下等待真实 Session handle 的 disposer。屏障阻塞处置,供测试检查所有权,然后释放屏障,等待完成并检查两个注册表。两个用例都不调用插件处置来触发待验证行为。独立的 teardown hook 在处置捕获的 Context 前释放屏障,包括测试体超时的情况。 + +[子 Agent 拆卸决策](2026-09-07-subagent-teardown-test-budgets.zh.md)负责生命周期清理预算。[持久 PowerShell 决策](2026-09-07-pwsh-ci-observable-completion.zh.md)负责精确与推断的终端就绪状态;一次性进程的完成 Promise 具有不同语义。 + +## 考虑过的替代方案 + +**增大独立等待时限。** 已有完成 Promise 时不采用。独立轮询期限仍会与执行通道的预算竞争。 + +**刷新反馈 golden。** 不采用:保留内容的输入框与禁用的附件按钮描述了尚未完成的提交。已稳定的预期 UI 仍是目标行为。 + +**串行化 CI 或重试这些测试。** 不采用:两者都不能建立缺失的完成条件,也不能在断言失败后释放阻塞的子进程。 + +## 后果 + +就绪与输出断言保留原有的内容和所有权检查。受控的 Desktop 就绪、Webhook 预检、命令响应、PowerShell 输出、publint 启动及 LSP 初始化延迟可复现原始失败,并在采用完成等待后通过。阻塞 Desktop worker 的控制用例仍报告测试超时,同时证明 teardown 在删除目录前等待子进程退出。执行通道分别限制测试体和清理 hook;PowerShell 和进程清理仍需在原生 Windows 上验证。 diff --git a/.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml deleted file mode 100644 index ebc503e4f4..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.md -2026-09-02-system-prompt-as-surface-node.md: 56577a7199235e95f4a7c6500140c8a8841d48dc -2026-09-02-system-prompt-as-surface-node.zh.md: da864fd300c93cae0210e758562b823c0a61679a diff --git a/.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.md b/.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.md deleted file mode 100644 index 56577a7199..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.md +++ /dev/null @@ -1,78 +0,0 @@ -# Agent Note: The system prompt is surface node 0 - -Status: proposed - -English | [中文](2026-09-02-system-prompt-as-surface-node.zh.md) - -## Problem - -The system prompt has a different durable representation from every other message the model reads. Conversation messages are surface events (`user/message`, `assistant/message`, `tool/result`) folded in seq order by `Session.deriveMessages()`; the system prompt is the `system` field of the log-only `request/header` snapshot, and each DeepSeek serializer prepends it as wire message 0 (`serializeRequest`, `serializeRequestWithImages`). The [reconstructable-requests Agent Note](../../implemented/architecture/2026-07-05-reconstructable-requests.md) made both halves durable, but it left one model-visible fact with two homes: the surface owns the messages, the header owns the message in front of them. - -That split forces every reader of "what did the model see" to join two sources. The compaction summarizer (`buildSummarizationInput`) copies `header.system` in front of the region's derived messages; `dsh-token-meter` estimates the system prompt from the header while pricing every other message from the surface; the Web request-prompt card, the trajectory view, and the snapshot normalizer's `{{system}}` placeholder each read the header on their own. The loop's change detection is also split: `headerEquals` compares `system` byte-for-byte beside `config` and `tools`, so a prompt change and a tool change are indistinguishable in the log (`request/header` reason `change`) even though they are different operations on the conversation. - -The split also blocks the next step. A model that accepts a mid-conversation `system` message as a prompt replacement needs the harness to append a system-role message to history; with the prompt living in the header there is no surface representation to append, and the header would have to be frozen by special case. The [in-history replacement proposal](../feature/2026-09-02-in-history-system-prompt-replacement.md) depends on this note. - -## Proposal - -Move the system prompt onto the surface. It becomes an ordinary surface event, `system/message`, and every prompt lifecycle operation is one of the two existing `SurfaceOp` variants applied to that event type. The wire request does not change: the surface fold yields the same message list the serializers already build today, with the system message first. - -### The event - -`system/message` joins `SurfaceEventType` beside `user/message`, `assistant/message`, and `tool/result`. Its payload mirrors `tool/result`: `{ turn, step, message }`, where `message` is a `Message` with `role: 'system'`, exactly one text block holding the rendered prompt, and source `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`. `deriveEventMessage` projects it verbatim, so `deriveMessages()` returns the system message at its surface position and both DeepSeek serializers, which already pass a `role: 'system'` history message through unchanged, emit it as wire message 0. `EpochHeader.system` is removed; the header keeps `config`, `adapterDefaults`, and `tools`. - -### The operations - -| Situation | Surface operation | -|---|---| -| First request of a session with a non-empty rendered prompt | append `system/message` as surface node 0, before the first `user/message` of the step | -| Rendered prompt differs from the prompt at node 0 | replace node 0: `surfaceOp: { op: 'replace', start: , end: }`, `sourceEventSeqs: []` | -| Rendered prompt is empty on the first request | no system node; a later non-empty prompt appends node 0 when the surface has no system node yet | - -Replacing node 0 is today's head rewrite expressed on the surface: the provider prefix changes from the first token, the log records the shadowed node through `sourceEventSeqs`, and `replaceGeneration` advances exactly as it does for a compaction replacement, so the loop's existing `startsSeries` detection (`requestSurfaceGeneration !== surfaceGeneration`) covers the prompt change without a `system` comparison in `headerEquals`. `request/header` keeps reasons `initial`, `resume`, `change`, and `series`; `change` now means config or tools changed. - -### Ownership in the loop - -`dsh-agent-loop` owns a `SystemPromptProjection` beside `RuntimeContextProjection` in `runtime-context.ts`. It restores the current system node from the log (the latest surviving `system/message` on the surface), follows `session/event` for new system nodes and for replacements whose `sourceEventSeqs` shadow the retained one, and returns the uncommitted append or replace intent when the rendered prompt differs. `turn()` commits that intent immediately before the step's `user/message` events, so the log order is the wire order. `step()` no longer passes `system` to `buildRequest`; the request is `header.config` plus `deriveMessages()` plus `header.tools`. The `dsh-agent-loop/invariant` companion keeps comparing the rebuilt request against the frozen one, now with the system message inside `messages`. `docs/architecture.md` records the new loop step order: claim, assemble, project system prompt, project runtime context, pre-step, commit system node, commit user messages, build request. - -### Consumers retargeted - -| Consumer | Today | After | -|---|---|---| -| DeepSeek serializers (`serializeRequest`, `serializeRequestWithImages`) | prepend `options.system` | serialize `options.messages` only; `GenerateOptions.system` remains for direct one-shot callers such as the summarizer and title providers | -| `compaction-basic` `buildSummarizationInput` | `header.system` + region messages | node 0's derived message + region messages, still a genuine prefix of the routed request | -| `compaction-basic` `selectCompactableRange` | head-anchored at `surfaceNodes[0]` | anchored at the first non-system node; node 0 is never inside a compaction range | -| `dsh-token-meter` system estimate | `header.system` length | the system node is priced like every other surface node; the context breakdown labels it by its source plugin | -| Web request-prompt card, trajectory request-header node, request inspection | read `header.system` | read the `system/message` node; the card keeps its collapsed inspectable presentation and is never a chat bubble | -| Snapshot normalizer `{{system}}` placeholder, plan-mode tests asserting `header.system` | header | the system node's text | -| TypeScript and Python SDK expected outputs | no system event | include the `system/message` event | -| Human transcript projections (`isAppendSurfaceEvent` readers) | no system events | skip `system/message`; it is model history, not conversation | - -`RuntimeContextProjection` and `SystemPromptProjection` are symmetric: both watch owned surface nodes and their shadowing through `sourceEventSeqs`, and both hand the loop an uncommitted message that `turn()` commits. The difference is the role and the operation set — runtime context appends user-role snapshots only, the system prompt appends once and then replaces. - -## Alternatives considered - -**Keep `header.system` and add `system/message` only for updates.** Two homes for one fact: every consumer above would read the header for message 0 and the surface for later messages, and the loop would need a special case that ignores `system` in `headerEquals` while a surface system node exists. Rejected because the point of the change is one representation. - -**A dedicated log-only `system-prompt/change` event that rewrites the header.** Preserves the header as the home of the prompt and records changes as their own event kind, but still cannot express a system message inside history, so the in-history proposal would need a second mechanism anyway. Rejected. - -**Synthesize the system message inside the adapter from consecutive headers.** The adapter is stateless per request and never sees the log; a wire history that depends on adapter state is not reconstructable from the surface fold. Rejected. - -**Express the prompt as a `user/message` snapshot like runtime context.** Reuses an existing event type but sends the wrong role, so a model that treats a system message as authoritative would not. Rejected. - -## Acceptance criteria - -- `SurfaceEventType` contains `system/message`; `deriveEventMessage` projects it; `Session.append('system/message', …)` requires a `SurfaceIntent` like the other surface events. -- `EpochHeader` has no `system` field; `headerEquals` compares `config`, `adapterDefaults`, and `tools` only. -- A first request with a non-empty rendered prompt appends `system/message` as surface node 0 before the step's first `user/message`; a changed prompt replaces node 0 with `sourceEventSeqs` naming the shadowed node; an unchanged prompt appends nothing. -- The DeepSeek wire request for every loop step is byte-identical to today's for the same session history: system first, then the folded conversation. -- Compaction never selects node 0; the summarizer's replayed prefix starts with node 0's derived message. -- `dsh-token-meter`, the Web request-prompt card, trajectory and inspection views, the snapshot normalizer, plan-mode tests, and both SDK expected outputs read the system node; the `dsh-agent-loop/invariant` companion rebuilds requests with the system message inside `messages`. -- Keyless recorded snapshots that exercise a mid-session prompt change (plan mode entering and leaving) show a replaced node 0 instead of a `request/header` `change`. -- `docs/architecture.md`, the `dsh-agent-loop`, `dsh-session`, `dsh-system-prompt`, `dsh-compaction-basic`, and `dsh-token-meter` READMEs, and the reconstructable-requests Agent Note describe the surface node as the home of the system prompt. - -## Risks - -- Every reader of `header.system` moves in one change; a missed reader fails at compile time because the field is gone, which is the intended failure mode. -- Compaction region selection gains an invariant (node 0 is never compacted). A compaction provider other than `compaction-basic` that anchors at `surfaceNodes[0]` would shadow the prompt; the `dsh-session` surface manager rejects a replacement whose range covers surface node 0 while node 0 is a `system/message` unless the replacing event is itself a `system/message` covering exactly that node, so the invariant is enforced where the operation happens, not only in the shipped provider. System nodes at later positions carry no such protection: a compaction range may shadow them. -- Replacing node 0 advances `replaceGeneration`, which today means "compaction happened" to some readers; those readers switch to inspecting the replacement event's type. -- Recorded snapshot fixtures whose logs contain `header.system` are re-recorded; the fixtures, not the normalizer, change. diff --git a/.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.zh.md b/.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.zh.md deleted file mode 100644 index da864fd300..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.zh.md +++ /dev/null @@ -1,78 +0,0 @@ -# Agent Note: 系统提示词是 surface 的第 0 号节点 - -Status: proposed - -[English](2026-09-02-system-prompt-as-surface-node.md) | 中文 - -## Problem - -系统提示词的持久化表示与模型读到的其他所有消息都不同。对话消息是 surface 事件(`user/message`、`assistant/message`、`tool/result`),由 `Session.deriveMessages()` 按 seq 顺序折叠;系统提示词则是仅记日志的 `request/header` 快照中的 `system` 字段,每个 DeepSeek 序列化器把它前置为协议消息 0(`serializeRequest`、`serializeRequestWithImages`)。[可重建请求 Agent Note](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md) 让两半都成为持久数据,却让一个模型可见的事实拥有两个归属:surface 拥有消息,header 拥有排在这些消息之前的那条消息。 - -这种拆分迫使每个想知道「模型看到了什么」的读取方都要合并两个来源。压缩摘要器(`buildSummarizationInput`)把 `header.system` 复制到区域派生消息之前;`dsh-token-meter` 从 header 估算系统提示词,却从 surface 为其他每条消息计价;Web 请求提示词卡片、轨迹视图和快照归一化器的 `{{system}}` 占位符各自单独读取 header。循环的变更检测同样被拆开:`headerEquals` 在 `config` 和 `tools` 旁边逐字节比较 `system`,因此提示词变更与工具变更在日志中无法区分(`request/header` 的 reason 都是 `change`),尽管它们是对对话的两种不同操作。 - -这种拆分还阻塞了下一步。一个把对话中途的 `system` 消息当作提示词替换来接受的模型,需要 harness 向历史追加一条 system 角色消息;当提示词住在 header 里时,没有可追加的 surface 表示,header 也只能靠特例被冻结。[历史内替换提案](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 依赖本 Agent Note。 - -## Proposal - -把系统提示词搬到 surface 上。它成为一个普通的 surface 事件 `system/message`,提示词生命周期中的每个操作都是对该事件类型施加现有两种 `SurfaceOp` 变体之一。协议请求不变:surface 折叠产出的消息列表与序列化器今天构建的完全相同,系统消息在最前面。 - -### 事件 - -`system/message` 加入 `SurfaceEventType`,与 `user/message`、`assistant/message`、`tool/result` 并列。它的载荷与 `tool/result` 对称:`{ turn, step, message }`,其中 `message` 是 `role: 'system'` 的 `Message`,恰好一个文本块承载渲染后的提示词,source 为 `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`。`deriveEventMessage` 逐字投影它,因此 `deriveMessages()` 在其 surface 位置返回系统消息,而两个 DeepSeek 序列化器本已原样透传 `role: 'system'` 的历史消息,会把它作为协议消息 0 发出。`EpochHeader.system` 被移除;header 保留 `config`、`adapterDefaults` 和 `tools`。 - -### 操作 - -| 情形 | surface 操作 | -|---|---| -| 会话首个请求且渲染后的提示词非空 | 追加 `system/message` 作为 surface 第 0 号节点,位于该步骤首条 `user/message` 之前 | -| 渲染后的提示词与第 0 号节点不同 | 替换第 0 号节点:`surfaceOp: { op: 'replace', start: <第 0 号节点的 seq>, end: <同一值> }`,`sourceEventSeqs: [<第 0 号节点的 seq>]` | -| 首个请求时渲染后的提示词为空 | 没有系统节点;之后出现非空提示词且 surface 尚无系统节点时,追加为第 0 号节点 | - -替换第 0 号节点就是今天的头部重写在 surface 上的表达:提供方前缀从第一个 token 起改变,日志通过 `sourceEventSeqs` 记录被遮蔽的节点,`replaceGeneration` 与压缩替换时一样推进,因此循环现有的 `startsSeries` 检测(`requestSurfaceGeneration !== surfaceGeneration`)无需在 `headerEquals` 中比较 `system` 即可覆盖提示词变更。`request/header` 保留 `initial`、`resume`、`change`、`series` 四种 reason;`change` 现在表示 config 或 tools 变更。 - -### 循环中的归属 - -`dsh-agent-loop` 在 `runtime-context.ts` 中与 `RuntimeContextProjection` 并列拥有一个 `SystemPromptProjection`。它从日志恢复当前系统节点(surface 上最新存活的 `system/message`),跟随 `session/event` 观察新的系统节点以及 `sourceEventSeqs` 遮蔽了所保留节点的替换,并在渲染后的提示词不同时返回未提交的追加或替换意图。`turn()` 紧接在该步骤的 `user/message` 事件之前提交该意图,因此日志顺序即协议顺序。`step()` 不再向 `buildRequest` 传递 `system`;请求由 `header.config`、`deriveMessages()` 和 `header.tools` 构成。`dsh-agent-loop/invariant` 伴随组件继续把重建的请求与冻结的请求比较,只是系统消息现在位于 `messages` 内。`docs/architecture.md` 记录新的循环步骤顺序:领取、装配、投影系统提示词、投影运行时上下文、pre-step、提交系统节点、提交用户消息、构建请求。 - -### 消费方迁移 - -| 消费方 | 现状 | 变更后 | -|---|---|---| -| DeepSeek 序列化器(`serializeRequest`、`serializeRequestWithImages`) | 前置 `options.system` | 只序列化 `options.messages`;`GenerateOptions.system` 为摘要器、标题提供方等直接单次调用方保留 | -| `compaction-basic` 的 `buildSummarizationInput` | `header.system` + 区域消息 | 第 0 号节点的派生消息 + 区域消息,仍是已路由请求的真实前缀 | -| `compaction-basic` 的 `selectCompactableRange` | 锚定在头部 `surfaceNodes[0]` | 锚定在首个非系统节点;第 0 号节点永不落入压缩范围 | -| `dsh-token-meter` 的系统提示词估算 | `header.system` 长度 | 系统节点与其他每个 surface 节点一样计价;上下文明细按其 source 插件标注 | -| Web 请求提示词卡片、轨迹请求 header 节点、请求检视 | 读取 `header.system` | 读取 `system/message` 节点;卡片保持折叠可检视的呈现,永不作为聊天气泡 | -| 快照归一化器的 `{{system}}` 占位符、断言 `header.system` 的 plan-mode 测试 | header | 系统节点的文本 | -| TypeScript 与 Python SDK 期望输出 | 没有系统事件 | 包含 `system/message` 事件 | -| 人类转录投影(`isAppendSurfaceEvent` 的读取方) | 没有系统事件 | 跳过 `system/message`;它是模型历史,不是对话 | - -`RuntimeContextProjection` 与 `SystemPromptProjection` 是对称的:两者都通过 `sourceEventSeqs` 观察自己拥有的 surface 节点及其被遮蔽的情况,都把一条未提交的消息交给循环由 `turn()` 提交。区别在于角色与操作集——运行时上下文只追加 user 角色快照,系统提示词追加一次之后只做替换。 - -## Alternatives considered - -**保留 `header.system`,只为更新添加 `system/message`。** 一个事实两个归属:上述每个消费方都要从 header 读消息 0、从 surface 读后续消息,循环还需要一个在 surface 存在系统节点时让 `headerEquals` 忽略 `system` 的特例。被否决,因为本次变更的目的就是单一表示。 - -**用专门的仅记日志事件 `system-prompt/change` 重写 header。** 保留 header 作为提示词归属,并把变更记录为独立事件种类,但仍无法表达历史内部的系统消息,历史内替换提案还是需要第二套机制。被否决。 - -**在适配器内根据相邻 header 合成系统消息。** 适配器逐请求无状态且从不接触日志;依赖适配器状态的协议历史无法从 surface 折叠重建。被否决。 - -**像运行时上下文那样用 `user/message` 快照表达提示词。** 复用了现有事件类型,却发送了错误的角色,因此把系统消息视为权威的模型不会这样对待它。被否决。 - -## Acceptance criteria - -- `SurfaceEventType` 包含 `system/message`;`deriveEventMessage` 投影它;`Session.append('system/message', …)` 与其他 surface 事件一样要求 `SurfaceIntent`。 -- `EpochHeader` 没有 `system` 字段;`headerEquals` 只比较 `config`、`adapterDefaults` 和 `tools`。 -- 渲染后的提示词非空的首个请求在该步骤首条 `user/message` 之前追加 `system/message` 作为 surface 第 0 号节点;提示词变更时以指明被遮蔽节点的 `sourceEventSeqs` 替换第 0 号节点;提示词不变时不追加任何内容。 -- 对同一会话历史,每个循环步骤的 DeepSeek 协议请求与今天逐字节一致:系统消息在先,随后是折叠后的对话。 -- 压缩永不选中第 0 号节点;摘要器回放的前缀以第 0 号节点的派生消息开头。 -- `dsh-token-meter`、Web 请求提示词卡片、轨迹与检视视图、快照归一化器、plan-mode 测试以及两个 SDK 的期望输出都读取系统节点;`dsh-agent-loop/invariant` 伴随组件重建请求时系统消息位于 `messages` 内。 -- 演练会话中途提示词变更(进入与退出 plan 模式)的无密钥录制快照显示被替换的第 0 号节点,而不是 `request/header` 的 `change`。 -- `docs/architecture.md`、`dsh-agent-loop`、`dsh-session`、`dsh-system-prompt`、`dsh-compaction-basic`、`dsh-token-meter` 的 README 以及可重建请求 Agent Note 都把 surface 节点描述为系统提示词的归属。 - -## Risks - -- `header.system` 的每个读取方在一次变更中迁移;遗漏的读取方因字段消失而在编译期失败,这正是预期的失败方式。 -- 压缩范围选择新增一条不变量(第 0 号节点永不被压缩)。除 `compaction-basic` 以外、锚定在 `surfaceNodes[0]` 的压缩提供方会遮蔽提示词;`dsh-session` 的 surface 管理器拒绝在第 0 号节点是 `system/message` 时覆盖第 0 号节点的替换,除非替换事件本身是恰好覆盖该节点的 `system/message`,因此不变量在操作发生处被强制,而不只在随发的提供方中。位于更后位置的系统节点没有此类保护:压缩范围可以遮蔽它们。 -- 替换第 0 号节点会推进 `replaceGeneration`,今天有些读取方把它理解为「发生了压缩」;这些读取方改为检查替换事件的类型。 -- 日志中包含 `header.system` 的录制快照 fixture 需要重新录制;改变的是 fixture,而不是归一化器。 diff --git a/.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml b/.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml deleted file mode 100644 index 6d808e5101..0000000000 --- a/.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.md -2026-09-02-in-history-system-prompt-replacement.md: 229e92500936ec8742f341c4dc18184eb9a2fe0c -2026-09-02-in-history-system-prompt-replacement.zh.md: f776f25a43f936024564488c1c53e9728fa19827 diff --git a/.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.md b/.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.md deleted file mode 100644 index 229e925009..0000000000 --- a/.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.md +++ /dev/null @@ -1,74 +0,0 @@ -# Agent Note: In-history system prompt replacement for cache-stable prompt changes - -Status: proposed - -English | [中文](2026-09-02-in-history-system-prompt-replacement.zh.md) - -## Problem - -Every system prompt change costs the whole provider prefix cache. The loop renders the prompt on every step; when the bytes differ — a plan-mode section entering or leaving, a skill or tool guidance section registering, an agent-scoped persona shadow, a changed `{{model}}` variable — the request's message 0 changes and the DeepSeek context cache misses from the first token. Long agentic sessions pay this repeatedly, and the [runtime-context snapshot design](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) exists precisely because moving a changing fact out of the prompt was the only way to keep the prefix stable. - -A DeepSeek model, provided as an unpublished model fact for this proposal, removes that constraint: it accepts a `system` message at any position of the conversation and treats the latest one as the complete effective system prompt, replacing the leading one. Tool schemas remain part of the cached prefix, so a tool-set change still invalidates the cache. With that model the harness can append the new prompt after the cached history instead of rewriting message 0, and the prefix stays warm. - -The harness has the representation for this only after the [system prompt is surface node 0](../architecture/2026-09-02-system-prompt-as-surface-node.md): a prompt change is then an operation on `system/message` surface nodes, and the choice between "replace node 0" and "append a new node" is a per-model decision. - -## Proposal - -For a model route that declares the capability, the loop appends a new `system/message` surface node instead of replacing node 0 when the rendered prompt changes and the prefix would otherwise survive. Everything else in the [surface-node design](../architecture/2026-09-02-system-prompt-as-surface-node.md) is unchanged: the event type, the projection owner, the serializers, and the presentation. - -### Capability - -The DeepSeek adapter's catalog model gains a validated optional field, `systemPromptUpdate`, with the single accepted value `'in-history'`; absence means the model needs message 0 rewritten. The adapter surfaces it on `LlmResolvedModelInfo` and `prepareCall()` returns it beside `context.contextWindow`, so the loop reads it from the same registration-bound metadata it already consumes. No default catalog entry declares it until the model is released; a deployment enables it through the `models` list in `cordis.yml`. Models without the field — including every current default entry and every `dsh-llm-pi-ai` route — keep the replace-node-0 behaviour exactly. - -### The decision rule - -`SystemPromptProjection` tracks the **effective prompt**: the text of the latest surviving `system/message` on the surface (node 0 when no later system node exists). When the rendered prompt differs from the effective prompt: - -| Route capability | Prefix state | Operation | -|---|---|---| -| none | any | replace node 0 | -| `in-history` | the current request series continues (no compaction since the last request, no tools or config change) | append a new `system/message` before the step's `user/message` events | -| `in-history` | a new series starts (compaction replaced the surface, or `request/header` records a `change` for tools or config) and no mid-history system node survives | replace node 0 with the current prompt | -| `in-history` | a new series starts but a mid-history system node survives | append a new `system/message`; node 0 stays as it is | - -The third row exists because a series start already costs the cache; folding the prompt back into node 0 keeps the history short. The fourth row exists because the surface has no delete operation: replacing node 0 while a later system node survives would leave the model reading the later, stale node as authoritative, so the loop appends instead. In-history mode never rewrites node 0 while any later system node survives. - -Resume follows the mid-session rule. A new loop instance restores the effective prompt from the log and, when the freshly rendered prompt differs, appends — the provider cache may still be warm across a process boundary, and the `resume` header is not a series start. - -### Presentation and accounting - -A mid-history `system/message` uses the same collapsed request-prompt inspection card as node 0, labelled as a prompt update at its position in the request; it is never a chat bubble, transcript projections skip it, and SDK projections expose it as a typed event. `dsh-token-meter` prices it like any other surface node, so the per-step context breakdown shows the accumulated cost of retained prompt versions until compaction shadows them. `cacheReadTokens` on the following `assistant/message` usage is the observable effect: for a capable route the value covers the prefix through the last cached message; for a non-capable route it drops to the shared-prefix detection floor. - -### Verification plan - -- Unit tests in `dsh-agent-loop` for the projection: append on a mid-series change, replace on a series start without surviving mid-history nodes, append on a series start with one, append on resume, no operation when unchanged, and replace-only behaviour for a route without the capability. -- Unit tests in `dsh-llm-deepseek` for catalog validation (`systemPromptUpdate` accepts `'in-history'` only) and for `prepareCall()` surfacing the field. -- A keyless recorded snapshot under `snapshots/` whose composition declares the capability on the mock route and toggles plan mode mid-session, pinning the appended `system/message` and the untouched node 0; TypeScript and Python SDK expected outputs include the appended event. -- A real-API e2e that runs two steps with a prompt change against a capable route and asserts that the second request's `cacheReadTokens` is at least the first request's prompt token count. It resolves its route from the standard credential and base-URL mechanism and self-skips when no capable route is configured. - -## Alternatives considered - -**Send only the changed sections as a delta.** The model treats the latest system message as the complete prompt, so a delta would silently drop every unchanged section. Rejected on the model contract. - -**Enable in-history mode by plugin config instead of a model capability.** A deployment flag could pair a non-capable model with appended system messages, which such a model would read as ordinary history at best. The capability belongs to the route that honours it; the adapter catalog already carries per-model capacities. Rejected. - -**Always append, never re-baseline.** One rule, but node 0 would stay stale for the life of the session and every request after compaction would carry the stale head plus the replacement. Re-baselining at a series start costs nothing extra because the cache is already lost there. Rejected. - -**Re-baseline on every resume.** Accepts one cache miss per process restart for a simpler resume path. The cache persists across restarts for hours to days, and the log already carries what resume needs. Rejected. - -**Place the system message after the step's user messages.** Both positions sit after the cached prefix, but the model then reads the instructions after the input it must apply them to; system-before-user matches the leading position's ordering. Rejected. - -## Acceptance criteria - -- `DeepSeekCatalogModel.systemPromptUpdate` is validated at load, exposed through `LlmResolvedModelInfo`, and returned by `prepareCall()`; a misspelt value fails at load. -- On a capable route a mid-series prompt change appends `system/message` before the step's `user/message` events and node 0 is unchanged; on a non-capable route the same change replaces node 0. -- On a capable route a series start with no surviving mid-history system node replaces node 0; with a surviving one it appends. -- A resumed loop instance whose rendered prompt differs appends on a capable route. -- The recorded snapshot and both SDK expected outputs pin the appended event; the e2e asserts the cache-hit inequality when a capable route is configured and skips otherwise. -- The `dsh-llm-deepseek`, `dsh-agent-loop`, and `dsh-system-prompt` READMEs document the capability, the decision rule, and the KV Cache effect; `docs/config-catalog.md` lists the field. - -## Risks - -- The model contract is unpublished; the note records it as provided. If the released model narrows it (for example, honouring only the latest system message within a bounded window), the decision rule needs a re-baseline trigger beyond series starts. -- Retained prompt versions accumulate in history until compaction shadows them. Each version costs its tokens on every request in the series; a deployment whose prompt changes on most steps would be better served by moving that fact into runtime context. -- A proxy that rewrites or reorders system messages breaks the replacement semantics silently; the e2e's cache-hit assertion is the detector. diff --git a/.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.zh.md b/.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.zh.md deleted file mode 100644 index f776f25a43..0000000000 --- a/.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.zh.md +++ /dev/null @@ -1,74 +0,0 @@ -# Agent Note: 历史内系统提示词替换,实现缓存稳定的提示词变更 - -Status: proposed - -[English](2026-09-02-in-history-system-prompt-replacement.md) | 中文 - -## Problem - -每一次系统提示词变更都要付出整个提供方前缀缓存的代价。循环在每个步骤渲染提示词;一旦字节不同——plan 模式片段进入或退出、某个 skill 或工具指引片段完成注册、agent 作用域的 persona 遮蔽、`{{model}}` 变量改变——请求的消息 0 随之改变,DeepSeek 上下文缓存从第一个 token 起失效。长时间的 agent 会话反复为此付费,而[运行时上下文快照设计](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)之所以存在,正是因为把会变化的事实移出提示词是保持前缀稳定的唯一办法。 - -一个 DeepSeek 模型——作为本提案所依据的未公开模型事实——移除了这一限制:它接受对话任意位置的 `system` 消息,并把最新一条视为完整的有效系统提示词,替换最前面那条。工具 schema 仍属于被缓存的前缀,因此工具集变更仍会使缓存失效。有了这样的模型,harness 可以把新提示词追加到已缓存的历史之后而不是重写消息 0,前缀就能保持热态。 - -只有在[系统提示词成为 surface 第 0 号节点](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md)之后,harness 才拥有实现这一点的表示:提示词变更随之成为对 `system/message` surface 节点的操作,而「替换第 0 号节点」与「追加新节点」之间的选择是逐模型的决定。 - -## Proposal - -对于声明了该能力的模型路由,当渲染后的提示词变化且前缀本可存活时,循环追加一个新的 `system/message` surface 节点而不是替换第 0 号节点。[surface 节点设计](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md)中的其他一切不变:事件类型、投影的拥有者、序列化器和呈现。 - -### 能力 - -DeepSeek 适配器的目录模型新增一个经校验的可选字段 `systemPromptUpdate`,唯一接受的值是 `'in-history'`;缺省表示该模型需要重写消息 0。适配器把它暴露在 `LlmResolvedModelInfo` 上,`prepareCall()` 在 `context.contextWindow` 旁边返回它,因此循环从它已经消费的同一份注册绑定元数据中读取。在该模型发布之前,没有默认目录条目声明它;部署方通过 `cordis.yml` 的 `models` 列表启用。没有该字段的模型——包括当前所有默认条目和所有 `dsh-llm-pi-ai` 路由——完全保持替换第 0 号节点的行为。 - -### 决策规则 - -`SystemPromptProjection` 跟踪**有效提示词**:surface 上最新存活的 `system/message` 的文本(不存在更后的系统节点时即第 0 号节点)。当渲染后的提示词与有效提示词不同时: - -| 路由能力 | 前缀状态 | 操作 | -|---|---|---| -| 无 | 任意 | 替换第 0 号节点 | -| `in-history` | 当前请求序列延续(上次请求以来没有压缩,tools 或 config 没有变更) | 在该步骤的 `user/message` 事件之前追加新的 `system/message` | -| `in-history` | 新序列开始(压缩替换了 surface,或 `request/header` 记录了 tools 或 config 的 `change`)且没有历史中途的系统节点存活 | 用当前提示词替换第 0 号节点 | -| `in-history` | 新序列开始但有历史中途的系统节点存活 | 追加新的 `system/message`;第 0 号节点保持原样 | - -第三行存在,是因为序列开始已经付出了缓存代价;把提示词折回第 0 号节点能让历史保持简短。第四行存在,是因为 surface 没有删除操作:在更后的系统节点仍存活时替换第 0 号节点,会让模型把更后、已过时的节点当作权威,所以循环改为追加。历史内模式在任何更后的系统节点存活期间永不重写第 0 号节点。 - -恢复遵循会话中途的规则。新的循环实例从日志恢复有效提示词,当新渲染的提示词不同时执行追加——提供方缓存在进程边界之后可能仍是热的,且 `resume` header 不是序列开始。 - -### 呈现与记账 - -历史中途的 `system/message` 使用与第 0 号节点相同的折叠请求提示词检视卡片,在请求中的对应位置标注为提示词更新;它永不作为聊天气泡,转录投影跳过它,SDK 投影把它暴露为带类型的事件。`dsh-token-meter` 像对待其他任何 surface 节点一样为它计价,因此逐步骤的上下文明细会显示被保留的各个提示词版本累计的开销,直到压缩遮蔽它们。随后 `assistant/message` 用量上的 `cacheReadTokens` 是可观察的效果:对具备能力的路由,该值覆盖到最后一条已缓存消息为止的前缀;对不具备能力的路由,它回落到公共前缀检测的下限。 - -### 验证计划 - -- `dsh-agent-loop` 中针对投影的单元测试:序列中途变更时追加、没有存活的历史中途节点时在序列开始处替换、有存活节点时在序列开始处追加、恢复时追加、未变更时无操作,以及不具备能力的路由只做替换。 -- `dsh-llm-deepseek` 中针对目录校验(`systemPromptUpdate` 只接受 `'in-history'`)和 `prepareCall()` 暴露该字段的单元测试。 -- `snapshots/` 下的一个无密钥录制快照,其组合在 mock 路由上声明该能力并在会话中途切换 plan 模式,钉住追加的 `system/message` 与未被触及的第 0 号节点;TypeScript 与 Python SDK 的期望输出包含追加的事件。 -- 一个真实 API 的 e2e:针对具备能力的路由运行两个步骤并夹带一次提示词变更,断言第二个请求的 `cacheReadTokens` 不小于第一个请求的提示词 token 数。它通过标准的凭据与 base-URL 机制解析路由,未配置具备能力的路由时自动跳过。 - -## Alternatives considered - -**只发送变化的片段作为增量。** 模型把最新的系统消息当作完整提示词,因此增量会静默丢掉每个未变化的片段。基于模型约定被否决。 - -**用插件配置而不是模型能力启用历史内模式。** 部署标志可能把不具备能力的模型与追加的系统消息配对,这样的模型最多把它们当作普通历史。该能力属于兑现它的路由;适配器目录已经承载逐模型的容量信息。被否决。 - -**永远追加,从不重新基线化。** 规则单一,但第 0 号节点会在会话整个生命周期内保持过时,压缩之后的每个请求都要携带过时的头部加替换消息。在序列开始处重新基线化不花额外代价,因为缓存在那里已经丢失。被否决。 - -**每次恢复都重新基线化。** 为更简单的恢复路径接受每次进程重启一次缓存未命中。缓存跨重启持续数小时到数天,而日志已经承载恢复所需的一切。被否决。 - -**把系统消息放在该步骤的用户消息之后。** 两个位置都在已缓存前缀之后,但模型会在读到必须应用指令的输入之后才读到指令;system 在 user 之前与最前位置的顺序一致。被否决。 - -## Acceptance criteria - -- `DeepSeekCatalogModel.systemPromptUpdate` 在加载时校验、通过 `LlmResolvedModelInfo` 暴露、由 `prepareCall()` 返回;拼错的值在加载时失败。 -- 在具备能力的路由上,序列中途的提示词变更在该步骤的 `user/message` 事件之前追加 `system/message`,第 0 号节点不变;在不具备能力的路由上,同样的变更替换第 0 号节点。 -- 在具备能力的路由上,没有存活的历史中途系统节点的序列开始替换第 0 号节点;有存活节点时追加。 -- 渲染后的提示词不同的已恢复循环实例在具备能力的路由上追加。 -- 录制快照与两个 SDK 的期望输出钉住追加的事件;配置了具备能力的路由时 e2e 断言缓存命中不等式,否则跳过。 -- `dsh-llm-deepseek`、`dsh-agent-loop`、`dsh-system-prompt` 的 README 记录该能力、决策规则和 KV Cache 效果;`docs/config-catalog.md` 列出该字段。 - -## Risks - -- 模型约定尚未公开;本 Agent Note 按所提供的内容记录。若发布的模型收窄了约定(例如只在有界窗口内兑现最新的系统消息),决策规则需要序列开始之外的重新基线化触发条件。 -- 被保留的提示词版本在历史中累积,直到压缩遮蔽它们。每个版本在该序列的每个请求上都要付出其 token 开销;提示词在多数步骤都变化的部署,更适合把那个事实移入运行时上下文。 -- 重写或重排系统消息的代理会静默破坏替换语义;e2e 的缓存命中断言是探测器。 diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md index 59eed9dca1..3616b9bba6 100644 --- a/.github/review-ownership/README.md +++ b/.github/review-ownership/README.md @@ -1,12 +1,13 @@ -# Automated review requests +# Automated pull-request reviews ## Summary -The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It classifies changed files, requests missing owners for reviewable code, and cancels its outstanding requests when a pull request becomes a draft. The ownership map is outside GitHub's native CODEOWNERS locations, so GitHub does not apply it directly. +The [`request-review` workflow](../workflows/request-review.yml) requests owners for reviewable code. The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Both write-capable workflows execute policy from the trusted default branch. ## Table of Contents - [Routing](#routing) +- [Approval scoring](#approval-scoring) - [Review exclusions](#review-exclusions) - [Security](#security) - [Verification](#verification) @@ -20,12 +21,26 @@ Pull requests run the workflow when opened, synchronized, reopened, marked ready For a non-draft pull request, the workflow keeps at most one current individual review request other than `@turtle1999`; an existing request for `@turtle1999` does not consume that slot. Each run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when made by people outside the ownership map. When more candidates remain than the available counted slot can cover, the workflow ranks them by the total GitHub-reported additions plus deletions in reviewable changed-file records that match each owner. A rename contributes its changed LOC once to an owner even when both paths match that owner. Higher changed LOC ranks first, and login order resolves ties. +Before selecting a new reviewer, a non-draft run fetches the pull request's complete chronological review list. An owner's latest undismissed decisive review is `APPROVED` or `CHANGES_REQUESTED`; comments and pending reviews do not replace that decision. An approved owner remains omitted after later synchronize events, while a later changes-requested review makes the owner eligible again. The workflow fails before mutation when the list reaches the supported 3,000-review limit or contains an invalid record. + On every run with current review requests, the workflow reads the pull-request timeline. A current reviewer is workflow-authored only when the latest matching `review_requested` event names `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. On a non-draft pull request, the workflow cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit. Current relevance order decides which matching workflow reviewer remains when the limit shrinks. It then fills any slot left by the planned cancellations. On a draft, it cancels every current workflow-authored request. Requests made by people remain unchanged in both states. An attributable event with invalid provenance fails before mutation, and the workflow also fails without cancellation when the timeline exceeds 3,000 events. -The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches and LOC; the aggregate owner relevance ranking; current individual requests and the available counted slot after planned cancellations; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author and users who remain requested are omitted from new requests. +The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches and LOC; the aggregate owner relevance ranking; approved owners omitted from new requests; current individual requests and the available counted slot after planned cancellations; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author, approved owners, and users who remain requested are omitted from new requests. The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase. + + +## Approval scoring + +The weighted approval workflow publishes the `weighted approval` commit status on the pull request head. Branch rules must require this status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The status succeeds at two approval points, remains pending below two points or while the pull request is a draft, fails while a write-capable reviewer has an effective `CHANGES_REQUESTED` review, and reports an error when policy evaluation fails. + +Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author and reviewers without write permission do not count. + +Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements. + +The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event. + ## Review exclusions @@ -42,15 +57,15 @@ For a modified file with a supported source extension, the scanner compares the ## Security -The write-capable `pull_request_target` job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Pull-request filenames are treated as API data and escaped in logs. +The write-capable jobs check out only the repository default branch. They do not check out or execute pull-request code and do not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request filenames and reviews are treated as API data and escaped in logs. -Ownership changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the routing program or its owner assignments for its own run. +Ownership and approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing either program or policy for its own run. ## Verification -Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI. +Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs both policy checks and the workflow tests in CI. diff --git a/.github/review-ownership/approval-policy.json b/.github/review-ownership/approval-policy.json new file mode 100644 index 0000000000..9ecc46ff5b --- /dev/null +++ b/.github/review-ownership/approval-policy.json @@ -0,0 +1,12 @@ +{ + "requiredPoints": 2, + "defaultPoints": 1, + "reviewerPoints": { + "07akioni": 2, + "imccyu": 2, + "tianyicui": 2, + "tianyicui-bot": 2, + "turtle1999": 2, + "turtle2099": 2 + } +} diff --git a/.github/review-ownership/check-approval.mjs b/.github/review-ownership/check-approval.mjs new file mode 100644 index 0000000000..ac354e601f --- /dev/null +++ b/.github/review-ownership/check-approval.mjs @@ -0,0 +1,377 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const API_VERSION = '2026-03-10' +const MAX_PULL_REQUEST_REVIEWS = 3_000 +const PAGE_SIZE = 100 +const STATUS_CONTEXT = 'weighted approval' +const STATUS_PREFIX = 'This is by automated Angry Turtle Cyborg, not a human' +const WRITABLE_PERMISSIONS = new Set(['admin', 'write']) +const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) +const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u + +class GitHubApiError extends Error { + constructor(message, status) { + super(message) + this.name = 'GitHubApiError' + this.status = status + } +} + +/** + * Parse the approval score policy. + * @param {string} source Approval policy JSON. + * @returns {{requiredPoints: number, defaultPoints: number, reviewerPoints: Map}} Validated policy. + */ +export function parseApprovalPolicy(source) { + const value = JSON.parse(source) + if (!isRecord(value)) throw new Error('approval policy must be an object') + const fields = Object.keys(value).sort() + if (fields.join(',') !== 'defaultPoints,requiredPoints,reviewerPoints') { + throw new Error('approval policy must contain only defaultPoints, requiredPoints, and reviewerPoints') + } + const requiredPoints = positiveInteger(value.requiredPoints, 'requiredPoints') + const defaultPoints = positiveInteger(value.defaultPoints, 'defaultPoints') + if (!isRecord(value.reviewerPoints)) throw new Error('reviewerPoints must be an object') + const reviewerPoints = new Map() + for (const [login, pointsValue] of Object.entries(value.reviewerPoints)) { + validateLogin(login, 'approval policy reviewer') + const key = login.toLowerCase() + if (reviewerPoints.has(key)) throw new Error(`duplicate approval policy reviewer @${login}`) + reviewerPoints.set(key, positiveInteger(pointsValue, `reviewerPoints.${login}`)) + } + return { requiredPoints, defaultPoints, reviewerPoints } +} + +/** + * Select each reviewer's current approval or change-request decision. + * @param {unknown[]} reviews Pull-request review records in GitHub's chronological order. + * @returns {Array<{login: string, state: 'APPROVED' | 'CHANGES_REQUESTED'}>} Effective review decisions. + */ +export function effectiveReviewDecisions(reviews) { + const decisions = new Map() + for (const review of reviews) { + if (!isRecord(review)) throw new Error('pull-request review is not an object') + if (review.user === null) continue + if (!isRecord(review.user) || typeof review.user.login !== 'string') { + throw new Error('pull-request review has no reviewer login') + } + const login = validateLogin(review.user.login, 'pull-request reviewer') + if (typeof review.state !== 'string' || !REVIEW_STATES.has(review.state.toUpperCase())) { + throw new Error(`pull-request review by @${login} has an invalid state`) + } + const state = review.state.toUpperCase() + const key = login.toLowerCase() + if (state === 'DISMISSED') { + decisions.delete(key) + } else if (state === 'APPROVED' || state === 'CHANGES_REQUESTED') { + decisions.set(key, { login, state }) + } + } + return [...decisions.values()] +} + +/** + * Create a repository-scoped GitHub JSON API caller. + * @param {{token: string, apiUrl?: string, fetchImpl?: typeof fetch}} options API dependencies. + * @returns {(path: string, options?: {method?: string, body?: unknown}) => Promise} API caller. + */ +export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = globalThis.fetch }) { + if (!token) throw new Error('GITHUB_TOKEN is not set') + if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable') + const root = apiUrl.replace(/\/+$/u, '') + return async (path, { method = 'GET', body } = {}) => { + const response = await fetchImpl(`${root}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'deepseek-harness-weighted-approval', + 'X-GitHub-Api-Version': API_VERSION, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + if (!response.ok) { + const responseBody = await response.text() + throw new GitHubApiError( + `GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`, + response.status, + ) + } + if (response.status === 204) return undefined + return response.json() + } +} + +/** + * Fetch every pull-request review or fail before scoring a partial list. + * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise} api GitHub API caller. + * @param {string} repository Owner/name repository identifier. + * @param {number} pullNumber Pull-request number. + * @returns {Promise} Complete review list within the supported limit. + */ +export async function listPullRequestReviews(api, repository, pullNumber) { + const reviews = [] + for (let page = 1; ; page++) { + const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`) + if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array') + reviews.push(...response) + if (response.length < PAGE_SIZE) return reviews + if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) { + throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} records`) + } + } +} + +/** + * Evaluate approval points from current reviews and repository permissions. + * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise}} options Runtime inputs. + * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'failure' | 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields. + */ +export async function evaluateApproval({ event, policySource, api }) { + const pull = pullRequestFromEvent(event) + const policy = parseApprovalPolicy(policySource) + if (pull.draft) { + return approvalResult(pull, policy.requiredPoints, [], [], [], 'pending', 'draft pull request') + } + + const reviews = await listPullRequestReviews(api, pull.repository, pull.number) + const decisions = effectiveReviewDecisions(reviews) + .filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase()) + const permissions = [] + for (const { login, state } of decisions) { + permissions.push({ login, state, permission: await reviewerPermission(api, pull.repository, login) }) + } + const approvals = [] + const blockers = [] + const ignoredReviewers = [] + for (const { login, state, permission } of permissions) { + if (!WRITABLE_PERMISSIONS.has(permission)) { + ignoredReviewers.push(login) + } else if (state === 'CHANGES_REQUESTED') { + blockers.push(login) + } else { + approvals.push({ + login, + points: policy.reviewerPoints.get(login.toLowerCase()) ?? policy.defaultPoints, + }) + } + } + approvals.sort((left, right) => left.login.localeCompare(right.login, 'en')) + blockers.sort((left, right) => left.localeCompare(right, 'en')) + ignoredReviewers.sort((left, right) => left.localeCompare(right, 'en')) + const points = approvals.reduce((total, approval) => { + const next = total + approval.points + if (!Number.isSafeInteger(next)) throw new Error('approval points exceed the safe integer range') + return next + }, 0) + if (blockers.length > 0) { + return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'failure', + `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`) + } + const state = points >= policy.requiredPoints ? 'success' : 'pending' + return approvalResult( + pull, + policy.requiredPoints, + approvals, + blockers, + ignoredReviewers, + state, + `${points}/${policy.requiredPoints} approval points`, + ) +} + +/** + * Evaluate and publish the required commit status, publishing an error status when evaluation fails. + * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, runUrl: string, write?: (line: string) => void}} options Runtime inputs. + * @returns {Promise>>} Published approval decision. + */ +export async function runApprovalCheck({ event, policySource, api, runUrl, write = line => process.stdout.write(`${line}\n`) }) { + const pull = pullRequestFromEvent(event) + write(STATUS_PREFIX) + let result + try { + result = await evaluateApproval({ event, policySource, api }) + } catch (error) { + await publishStatus(api, pull, 'error', `${STATUS_PREFIX}: approval evaluation failed.`, runUrl) + throw error + } + write(`Approval score: ${result.points}/${result.requiredPoints}.`) + writeList(write, 'Counted approvals', result.approvals.map(({ login, points }) => `@${login}: ${points}`)) + writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`)) + writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`)) + await publishStatus(api, pull, result.state, result.description, runUrl) + write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`) + return result +} + +/** + * Resolve the reviewed pull request from a completed review-event workflow run. + * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise}} options Trusted workflow inputs. + * @returns {Promise | null>} Event with a current pull request, or null after the pull-request head changes. + */ +export async function approvalEventFromWorkflowRun({ event, api }) { + const repository = repositoryFromEvent(event) + if (!isRecord(event.workflow_run) || event.workflow_run.name !== 'weighted-approval-review-event' + || event.workflow_run.event !== 'pull_request_review' || event.workflow_run.conclusion !== 'success') { + throw new Error('event has no successful weighted approval review workflow run') + } + const expectedHeadSha = validateHeadSha(event.workflow_run.head_sha, 'workflow run') + const pullNumber = parsePullNumber(event.workflow_run.display_title) + if (!Array.isArray(event.workflow_run.pull_requests)) { + throw new Error('workflow run has no pull_requests array') + } + if (event.workflow_run.pull_requests.length > 0 + && !event.workflow_run.pull_requests.some(pull => isRecord(pull) && pull.number === pullNumber)) { + throw new Error(`workflow run is not associated with pull request #${pullNumber}`) + } + const pull = await api(`/repos/${repository}/pulls/${pullNumber}`) + if (!isRecord(pull) || !isRecord(pull.head)) throw new Error(`pull request #${pullNumber} response is invalid`) + if (pull.head.sha !== expectedHeadSha) return null + return { ...event, pull_request: pull } +} + +function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail) { + return { + pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha }, + state, + description: `${STATUS_PREFIX}: ${detail}.`, + points: approvals.reduce((total, approval) => total + approval.points, 0), + requiredPoints, + approvals, + blockers, + ignoredReviewers, + } +} + +async function reviewerPermission(api, repository, login) { + let response + try { + response = await api(`/repos/${repository}/collaborators/${encodeURIComponent(login)}/permission`) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return 'none' + throw error + } + if (!isRecord(response) || typeof response.permission !== 'string') { + throw new Error(`collaborator permission response for @${login} has no permission`) + } + return response.permission.toLowerCase() +} + +async function publishStatus(api, pull, state, description, runUrl) { + if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS') + if (description.length > 140) throw new Error('commit status description exceeds 140 characters') + await api(`/repos/${pull.repository}/statuses/${pull.headSha}`, { + method: 'POST', + body: { + state, + context: STATUS_CONTEXT, + description, + target_url: runUrl, + }, + }) +} + +function pullRequestFromEvent(event) { + const repository = repositoryFromEvent(event) + if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user) + || !isRecord(event.pull_request.head)) { + throw new Error('event has no pull_request') + } + const pull = event.pull_request + if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number') + if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag') + const author = validateLogin(pull.user.login, 'pull-request author') + const headSha = validateHeadSha(pull.head.sha, 'pull request') + return { + repository, + number: pull.number, + draft: pull.draft, + author, + headSha, + } +} + +function repositoryFromEvent(event) { + if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string' + || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(event.repository.full_name)) { + throw new Error('event has no valid repository.full_name') + } + return event.repository.full_name +} + +function validateHeadSha(value, subject) { + if (typeof value !== 'string' || !/^[0-9a-f]{40}$/u.test(value)) { + throw new Error(`${subject} has no valid head SHA`) + } + return value +} + +function parsePullNumber(source) { + if (typeof source !== 'string') throw new Error('review event has no valid run title') + const match = /^weighted-approval-review-event:([1-9][0-9]*)$/u.exec(source) + if (!match) throw new Error('review event has no valid run title') + const pullNumber = Number(match[1]) + if (!Number.isSafeInteger(pullNumber)) throw new Error('review event pull request number is not a safe integer') + return pullNumber +} + +function positiveInteger(value, field) { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${field} must be a positive integer`) + return value +} + +function validateLogin(value, subject) { + if (typeof value !== 'string' || !LOGIN.test(value)) throw new Error(`${subject} has an invalid login`) + return value +} + +function writeList(write, title, entries) { + write(`${title}:`) + if (entries.length === 0) write('- (none)') + else for (const entry of entries) write(`- ${entry}`) +} + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function main() { + const eventPath = process.env.GITHUB_EVENT_PATH + if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set') + let event = JSON.parse(readFileSync(eventPath, 'utf8')) + const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8') + const api = createGitHubApi({ + token: process.env.GITHUB_TOKEN ?? '', + apiUrl: process.env.GITHUB_API_URL, + }) + if (isRecord(event) && isRecord(event.workflow_run)) { + const resolved = await approvalEventFromWorkflowRun({ + event, + api, + }) + if (resolved === null) { + process.stdout.write(`${STATUS_PREFIX}\n`) + process.stdout.write('Skipped a review event for a superseded pull-request head.\n') + return + } + event = resolved + } + await runApprovalCheck({ + event, + policySource, + api, + runUrl: process.env.GITHUB_RUN_URL ?? '', + }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`weighted approval failed: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs new file mode 100644 index 0000000000..9a1412b25a --- /dev/null +++ b/.github/review-ownership/check-approval.test.mjs @@ -0,0 +1,317 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + approvalEventFromWorkflowRun, + createGitHubApi, + effectiveReviewDecisions, + evaluateApproval, + listPullRequestReviews, + parseApprovalPolicy, + runApprovalCheck, +} from './check-approval.mjs' + +const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8') +const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678' + +const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({ + repository: { full_name: 'deepseek-harness/deepseek-harness' }, + pull_request: { + number: 42, + draft, + user: { login: author }, + head: { sha: HEAD_SHA }, + }, +}) + +const review = (login, state) => ({ user: { login }, state }) + +test('loads the repository approval score policy', () => { + const policy = parseApprovalPolicy(policySource) + assert.equal(policy.requiredPoints, 2) + assert.equal(policy.defaultPoints, 1) + assert.deepEqual([...policy.reviewerPoints], [ + ['07akioni', 2], + ['imccyu', 2], + ['tianyicui', 2], + ['tianyicui-bot', 2], + ['turtle1999', 2], + ['turtle2099', 2], + ]) +}) + +test('rejects invalid approval score policies', () => { + for (const [source, message] of [ + ['[]', /must be an object/u], + ['{"requiredPoints":0,"defaultPoints":1,"reviewerPoints":{}}', /requiredPoints/u], + ['{"requiredPoints":2,"defaultPoints":0,"reviewerPoints":{}}', /defaultPoints/u], + ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":[]}', /reviewerPoints must be an object/u], + ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{},"typo":2}', /contain only/u], + ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{"bad login":2}}', /invalid login/u], + ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{"User":2,"user":2}}', /duplicate/u], + ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{"user":-1}}', /positive integer/u], + ]) { + assert.throws(() => parseApprovalPolicy(source), message) + } +}) + +test('uses each reviewer current decision and clears it on dismissal', () => { + assert.deepEqual(effectiveReviewDecisions([ + review('first', 'APPROVED'), + review('first', 'APPROVED'), + review('first', 'COMMENTED'), + review('first', 'DISMISSED'), + review('second', 'CHANGES_REQUESTED'), + review('second', 'APPROVED'), + review('third', 'APPROVED'), + review('third', 'CHANGES_REQUESTED'), + review('dismissed', 'DISMISSED'), + { user: null, state: 'APPROVED' }, + ]), [ + { login: 'second', state: 'APPROVED' }, + { login: 'third', state: 'CHANGES_REQUESTED' }, + ]) +}) + +test('resolves a review workflow run to the current pull request and rejects stale heads', async () => { + const workflowRunEvent = { + repository: { full_name: 'deepseek-harness/deepseek-harness' }, + workflow_run: { + name: 'weighted-approval-review-event', + event: 'pull_request_review', + conclusion: 'success', + head_sha: HEAD_SHA, + display_title: 'weighted-approval-review-event:42', + pull_requests: [], + }, + } + const current = await approvalEventFromWorkflowRun({ + event: workflowRunEvent, + api: async path => { + assert.equal(path, '/repos/deepseek-harness/deepseek-harness/pulls/42') + return pullRequestEvent().pull_request + }, + }) + assert.equal(current.pull_request.number, 42) + + assert.equal(await approvalEventFromWorkflowRun({ + event: workflowRunEvent, + api: async () => ({ + ...pullRequestEvent().pull_request, + head: { sha: 'abcdef1234567890abcdef1234567890abcdef12' }, + }), + }), null) + await assert.rejects(approvalEventFromWorkflowRun({ + event: { + ...workflowRunEvent, + workflow_run: { ...workflowRunEvent.workflow_run, display_title: '../42' }, + }, + api: async () => { throw new Error('invalid number must not call GitHub') }, + }), /valid run title/u) +}) + +test('fetches every pull-request review and rejects an unbounded history', async () => { + let calls = 0 + const reviews = await listPullRequestReviews(async () => { + calls++ + return calls === 1 ? Array.from({ length: 100 }, () => review('user', 'COMMENTED')) : [] + }, 'owner/repo', 42) + assert.equal(reviews.length, 100) + assert.equal(calls, 2) + + calls = 0 + await assert.rejects(listPullRequestReviews(async () => { + calls++ + return Array.from({ length: 100 }, () => review('user', 'COMMENTED')) + }, 'owner/repo', 42), /exceed 3000/u) + assert.equal(calls, 30) +}) + +test('accepts one two-point approval from a write-capable reviewer', async () => { + const calls = [] + const result = await evaluateApproval({ + event: pullRequestEvent(), + policySource, + api: async (path) => { + calls.push(path) + if (path.includes('/reviews?')) return [review('07akioni', 'APPROVED')] + if (path.includes('/collaborators/07akioni/permission')) return { permission: 'write' } + throw new Error(`unexpected API path ${path}`) + }, + }) + assert.equal(result.state, 'success') + assert.equal(result.points, 2) + assert.deepEqual(result.approvals, [{ login: '07akioni', points: 2 }]) + assert.equal(calls.length, 2) +}) + +test('accepts two one-point approvals and ignores reviews without write access', async () => { + const result = await evaluateApproval({ + event: pullRequestEvent(), + policySource, + api: async (path) => { + if (path.includes('/reviews?')) { + return [ + review('reader', 'APPROVED'), + review('writer-b', 'APPROVED'), + review('writer-a', 'APPROVED'), + ] + } + if (path.includes('/collaborators/reader/permission')) return { permission: 'read' } + if (path.includes('/collaborators/writer-a/permission')) return { permission: 'admin' } + if (path.includes('/collaborators/writer-b/permission')) return { permission: 'write' } + throw new Error(`unexpected API path ${path}`) + }, + }) + assert.equal(result.state, 'success') + assert.equal(result.points, 2) + assert.deepEqual(result.approvals, [ + { login: 'writer-a', points: 1 }, + { login: 'writer-b', points: 1 }, + ]) + assert.deepEqual(result.ignoredReviewers, ['reader']) +}) + +test('keeps one one-point approval pending without failing the status', async () => { + const result = await evaluateApproval({ + event: pullRequestEvent(), + policySource, + api: async (path) => { + if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] + if (path.includes('/collaborators/writer/permission')) return { permission: 'write' } + throw new Error(`unexpected API path ${path}`) + }, + }) + assert.equal(result.state, 'pending') + assert.equal(result.points, 1) +}) + +test('ignores a reviewer whose collaborator permission lookup returns 404', async () => { + const api = createGitHubApi({ + token: 'secret', + fetchImpl: async (url) => { + if (url.includes('/reviews?')) { + return new Response(JSON.stringify([review('former-writer', 'APPROVED')]), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (url.includes('/collaborators/former-writer/permission')) return new Response('Not Found', { status: 404 }) + throw new Error(`unexpected API URL ${url}`) + }, + }) + const result = await evaluateApproval({ event: pullRequestEvent(), policySource, api }) + assert.equal(result.state, 'pending') + assert.deepEqual(result.ignoredReviewers, ['former-writer']) +}) + +test('blocks on a write-capable change request but ignores the author and read-only blockers', async () => { + const result = await evaluateApproval({ + event: pullRequestEvent({ author: 'author' }), + policySource, + api: async (path) => { + if (path.includes('/reviews?')) { + return [ + review('turtle1999', 'APPROVED'), + review('blocker', 'CHANGES_REQUESTED'), + review('reader', 'CHANGES_REQUESTED'), + review('author', 'CHANGES_REQUESTED'), + ] + } + if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' } + if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' } + if (path.includes('/collaborators/reader/permission')) return { permission: 'read' } + throw new Error(`unexpected API path ${path}`) + }, + }) + assert.equal(result.state, 'failure') + assert.equal(result.points, 2) + assert.deepEqual(result.blockers, ['blocker']) + assert.deepEqual(result.ignoredReviewers, ['reader']) +}) + +test('keeps drafts pending without reading reviews', async () => { + const result = await evaluateApproval({ + event: pullRequestEvent({ draft: true }), + policySource, + api: async () => { throw new Error('draft evaluation must not call GitHub') }, + }) + assert.equal(result.state, 'pending') + assert.equal(result.points, 0) + assert.match(result.description, /draft pull request/u) +}) + +test('publishes the required status and replaces stale success with error on evaluation failure', async () => { + const calls = [] + const output = [] + const result = await runApprovalCheck({ + event: pullRequestEvent(), + policySource, + runUrl: 'https://github.example/actions/runs/1', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')] + if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' } + if (path.includes('/statuses/')) return {} + throw new Error(`unexpected API path ${path}`) + }, + write: line => output.push(line), + }) + assert.equal(result.state, 'success') + assert.deepEqual(calls.at(-1), { + path: `/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`, + options: { + method: 'POST', + body: { + state: 'success', + context: 'weighted approval', + description: 'This is by automated Angry Turtle Cyborg, not a human: 2/2 approval points.', + target_url: 'https://github.example/actions/runs/1', + }, + }, + }) + assert.equal(output[0], 'This is by automated Angry Turtle Cyborg, not a human') + + const failures = [] + await assert.rejects(runApprovalCheck({ + event: pullRequestEvent(), + policySource, + runUrl: 'https://github.example/actions/runs/2', + api: async (path, options = {}) => { + if (path.includes('/reviews?')) throw new Error('reviews unavailable') + if (path.includes('/statuses/')) { + failures.push({ path, options }) + return {} + } + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }), /reviews unavailable/u) + assert.equal(failures[0].options.body.state, 'error') +}) + +test('sends authenticated JSON and escapes an API error body', async () => { + const requests = [] + const api = createGitHubApi({ + token: 'secret', + apiUrl: 'https://github.example/api/v3/', + fetchImpl: async (url, options) => { + requests.push({ url, options }) + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }, + }) + assert.deepEqual(await api('/repos/owner/repo', { method: 'POST', body: { value: 1 } }), { ok: true }) + assert.equal(requests[0].url, 'https://github.example/api/v3/repos/owner/repo') + assert.equal(requests[0].options.headers.Authorization, 'Bearer secret') + assert.equal(requests[0].options.body, '{"value":1}') + + const failing = createGitHubApi({ + token: 'secret', + fetchImpl: async () => new Response('::error::untrusted\nbody', { status: 422 }), + }) + await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u) +}) diff --git a/.github/review-ownership/request-review.mjs b/.github/review-ownership/request-review.mjs index b9cc8a01ab..c2d1ee7f43 100644 --- a/.github/review-ownership/request-review.mjs +++ b/.github/review-ownership/request-review.mjs @@ -7,9 +7,11 @@ import { pathToFileURL } from 'node:url' const API_VERSION = '2026-03-10' const MAX_OWNERS_PER_RULE = 2 const MAX_PULL_REQUEST_FILES = 3_000 +const MAX_PULL_REQUEST_REVIEWS = 3_000 const MAX_COUNTED_REQUESTED_REVIEWERS = 1 const MAX_TIMELINE_EVENTS = 3_000 const PAGE_SIZE = 100 +const PULL_REQUEST_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) const UNCOUNTED_REVIEWER = 'turtle1999' const WORKFLOW_REVIEW_REQUESTER = 'github-actions[bot]' const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests']) @@ -384,6 +386,47 @@ export async function listPullRequestFiles(api, repository, pullNumber, expected return files } +/** + * Fetch the complete chronological pull-request review list. + * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise} api GitHub API caller. + * @param {string} repository Owner/name repository identifier. + * @param {number} pullNumber Pull-request number. + * @returns {Promise} Complete review list within the supported limit. + */ +export async function listPullRequestReviews(api, repository, pullNumber) { + const reviews = [] + for (let page = 1; ; page++) { + const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`) + if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array') + reviews.push(...response) + if (response.length < PAGE_SIZE) return reviews + if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) { + throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} entries`) + } + } +} + +/** + * Return users whose latest undismissed decisive review approves the pull request. + * @param {unknown[]} reviews Chronological GitHub pull-request review records. + * @returns {string[]} Approved reviewer logins in stable order. + */ +export function approvedReviewerLogins(reviews) { + const approved = new Map() + for (const review of reviews) { + if (!isRecord(review) || !isRecord(review.user) || typeof review.user.login !== 'string') { + throw new Error('pull-request reviews response contains an invalid reviewer') + } + if (typeof review.state !== 'string' || !PULL_REQUEST_REVIEW_STATES.has(review.state)) { + throw new Error('pull-request reviews response contains an invalid state') + } + const key = review.user.login.toLowerCase() + if (review.state === 'APPROVED') approved.set(key, review.user.login) + else if (review.state === 'CHANGES_REQUESTED') approved.delete(key) + } + return [...approved.values()].sort((left, right) => left.localeCompare(right, 'en')) +} + /** * Fetch the pull request timeline used to identify workflow-authored review requests. * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise} api GitHub API caller. @@ -472,7 +515,7 @@ export async function requestReviews({ event, ownershipSource, api, write = line plan.reviewers.map(({ login, changedLines }) => `@${login}: ${changedLines}`), ) - const candidates = plan.reviewers.filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase()) + const ownerCandidates = plan.reviewers.filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase()) if (pull.draft) { const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`) const requestedReviewers = requestedReviewerLogins(existing) @@ -494,6 +537,16 @@ export async function requestReviews({ event, ownershipSource, api, write = line return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers } } + const approvedReviewerKeys = new Set( + (ownerCandidates.length === 0 + ? [] + : approvedReviewerLogins(await listPullRequestReviews(api, pull.repository, pull.number))) + .map(login => login.toLowerCase()), + ) + const approvedOwners = ownerCandidates.filter(({ login }) => approvedReviewerKeys.has(login.toLowerCase())) + const candidates = ownerCandidates.filter(({ login }) => !approvedReviewerKeys.has(login.toLowerCase())) + writeList(write, 'Approved owners omitted from review requests', approvedOwners.map(({ login }) => `@${login}`)) + const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`) const currentReviewers = requestedReviewerLogins(existing).sort((left, right) => left.localeCompare(right, 'en')) const workflowReviewers = currentReviewers.length === 0 diff --git a/.github/review-ownership/request-review.test.mjs b/.github/review-ownership/request-review.test.mjs index 957feee2da..fdb3d763a4 100644 --- a/.github/review-ownership/request-review.test.mjs +++ b/.github/review-ownership/request-review.test.mjs @@ -4,12 +4,14 @@ import { existsSync, readFileSync } from 'node:fs' import test from 'node:test' import { + approvedReviewerLogins, classifyChangedFiles, createGitHubApi, isCommentOnlyChange, isDocumentationPath, isTestPath, listPullRequestFiles, + listPullRequestReviews, listPullRequestTimeline, normalizeRepositoryPath, parseOwnership, @@ -333,6 +335,57 @@ test('fails closed when GitHub cannot provide the complete file list', async () ) }) +test('fetches pull-request reviews across pages', async () => { + const calls = [] + const pageOne = Array.from({ length: 100 }, (_, index) => ({ + user: { login: `reviewer-${index}` }, + state: 'COMMENTED', + })) + const pageTwo = [{ user: { login: 'approver' }, state: 'APPROVED' }] + const reviews = await listPullRequestReviews(async (path) => { + calls.push(path) + return calls.length === 1 ? pageOne : pageTwo + }, 'owner/repo', 42) + + assert.equal(reviews.length, 101) + assert.deepEqual(calls, [ + '/repos/owner/repo/pulls/42/reviews?per_page=100&page=1', + '/repos/owner/repo/pulls/42/reviews?per_page=100&page=2', + ]) +}) + +test('tracks each reviewer\'s latest undismissed approval decision', () => { + assert.deepEqual(approvedReviewerLogins([ + { user: { login: 'commented-after' }, state: 'APPROVED' }, + { user: { login: 'commented-after' }, state: 'COMMENTED' }, + { user: { login: 'changes-after' }, state: 'APPROVED' }, + { user: { login: 'changes-after' }, state: 'CHANGES_REQUESTED' }, + { user: { login: 'dismissed' }, state: 'DISMISSED' }, + { user: { login: 'approved-after' }, state: 'CHANGES_REQUESTED' }, + { user: { login: 'approved-after' }, state: 'APPROVED' }, + { user: { login: 'pending-after' }, state: 'APPROVED' }, + { user: { login: 'pending-after' }, state: 'PENDING' }, + ]), ['approved-after', 'commented-after', 'pending-after']) + + assert.throws( + () => approvedReviewerLogins([{ user: { login: 'reviewer' }, state: 'UNKNOWN' }]), + /invalid state/u, + ) + assert.throws(() => approvedReviewerLogins([{ state: 'APPROVED' }]), /invalid reviewer/u) +}) + +test('fails closed when the pull-request review list exceeds its limit', async () => { + let calls = 0 + await assert.rejects( + listPullRequestReviews(async () => { + calls++ + return Array.from({ length: 100 }, () => ({ user: { login: 'reviewer' }, state: 'COMMENTED' })) + }, 'owner/repo', 42), + /exceed 3000 entries/u, + ) + assert.equal(calls, 30) +}) + test('fails closed when the review-request timeline exceeds its limit', async () => { let calls = 0 await assert.rejects( @@ -358,6 +411,7 @@ test('prints changed code files and requests the highest-ranked counted owner', const api = async (path, options = {}) => { trace.push({ type: 'api', path, options }) if (path.endsWith('/files?per_page=100&page=1')) return files + if (path.endsWith('/reviews?per_page=100&page=1')) return [] if (path.endsWith('/requested_reviewers') && options.method !== 'POST') { return { users: [], teams: [] } } @@ -409,6 +463,67 @@ test('prints changed code files and requests the highest-ranked counted owner', }) }) +test('does not request an owner again after that owner approves', async () => { + const calls = [] + const output = [] + const result = await requestReviews({ + event: pullRequestEvent(), + ownershipSource: '/packages/typert/ @imccyu\n', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [{ filename: 'packages/typert/generator/src/analyzer.ts', additions: 150, deletions: 47 }] + } + if (path.endsWith('/reviews?per_page=100&page=1')) { + return [ + { user: { login: 'imccyu' }, state: 'APPROVED' }, + { user: { login: 'imccyu' }, state: 'COMMENTED' }, + ] + } + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [], teams: [] } + } + throw new Error(`unexpected API path ${path}`) + }, + write: line => output.push(line), + }) + + assert.deepEqual(result.requestedReviewers, []) + assert.equal(calls.some(call => call.options.method === 'POST'), false) + const approvedHeading = output.indexOf('Approved owners omitted from review requests:') + assert.ok(approvedHeading >= 0) + assert.equal(output[approvedHeading + 1], '- @imccyu') +}) + +test('fills the counted slot with the next owner after omitting an approved owner', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent(), + ownershipSource: '/packages/core/ @imccyu @mektpoy\n', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] + } + if (path.endsWith('/reviews?per_page=100&page=1')) { + return [{ user: { login: 'imccyu' }, state: 'APPROVED' }] + } + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [], teams: [] } + } + if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }) + + assert.deepEqual(result.requestedReviewers, ['mektpoy']) + assert.deepEqual(calls.find(call => call.options.method === 'POST'), { + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'POST', body: { reviewers: ['mektpoy'] } }, + }) +}) + test('does not add another counted owner when one is already requested', async () => { const calls = [] const output = [] @@ -420,6 +535,7 @@ test('does not add another counted owner when one is already requested', async ( if (path.endsWith('/files?per_page=100&page=1')) { return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] if (path.endsWith('/requested_reviewers') && options.method === undefined) { return { users: [{ login: 'first' }], teams: [] } } @@ -455,6 +571,7 @@ test('requests at most one owner per run when turtle ranks first', async () => { { filename: 'packages/client/store/src/index.ts', additions: 8, deletions: 2 }, ] } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] if (path.endsWith('/requested_reviewers') && options.method === undefined) { return { users: [], teams: [] } } @@ -481,6 +598,7 @@ test('does not add turtle when one counted reviewer is already requested', async if (path.endsWith('/files?per_page=100&page=1')) { return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] if (path.endsWith('/requested_reviewers') && options.method === undefined) { return { users: [{ login: 'first' }], teams: [] } } @@ -504,6 +622,7 @@ test('keeps the counted slot available when turtle is already requested', async if (path.endsWith('/files?per_page=100&page=1')) { return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] if (path.endsWith('/requested_reviewers') && options.method === undefined) { return { users: [{ login: 'turtle1999' }], teams: [] } } @@ -531,6 +650,7 @@ test('replaces a workflow reviewer that no longer matches current ownership', as if (path.endsWith('/files?per_page=100&page=1')) { return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] if (path.endsWith('/requested_reviewers') && options.method === undefined) { return { users: [{ login: 'Dudu-0223' }], teams: [] } } @@ -583,6 +703,7 @@ test('removes excess workflow reviewers using current relevance order', async () { filename: 'packages/subagent/subagent/src/index.ts', additions: 8, deletions: 2 }, ] } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] if (path.endsWith('/requested_reviewers') && options.method === undefined) { return { users: [{ login: 'Dudu-0223' }, { login: 'mektpoy' }], teams: [] } } diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml deleted file mode 100644 index 6379c8cdc1..0000000000 --- a/.github/workflows/landlock-run.yml +++ /dev/null @@ -1,144 +0,0 @@ -# CI for the landlock-run packages under native/landlock-run. A separate -# workflow from ci.yml keeps the native OS/architecture matrix independent of -# the harness Node matrix. Release assembly and publication use the companion -# Landlock Run Release workflow. -name: Landlock Run - -on: - pull_request: - paths: - - '.github/workflows/landlock-run.yml' - - '.github/workflows/landlock-run-release.yml' - - 'native/landlock-run/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - push: - branches: [master] - paths: - - '.github/workflows/landlock-run.yml' - - '.github/workflows/landlock-run-release.yml' - - 'native/landlock-run/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -env: - # CI runs must never report to the production telemetry endpoint baked - # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). - DSH_TELEMETRY_DISABLED: '1' - -defaults: - run: - working-directory: native/landlock-run - -jobs: - matrix: - name: Matrix - runs-on: ubuntu-24.04 - outputs: - ci: ${{ steps.matrix.outputs.ci }} - steps: - - uses: actions/checkout@v4 - - - id: matrix - run: echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT" - - native: - name: ${{ matrix.platform }} - needs: matrix - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.matrix.outputs.ci) }} - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - package_json_file: package.json - - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - - - name: Install musl toolchain - run: | - sudo apt-get update -q - sudo apt-get install -yq musl-tools - - - name: Build TypeScript - run: pnpm build:ts - - - name: Typecheck - run: pnpm typecheck - - - name: Build native binaries (this architecture is the builder of record) - run: pnpm build:native - - - name: Entry tests (keyless) - run: node ./test/entry.test.js - - # NALR_REQUIRE_LANDLOCK: a self-skip on the very platform that exists to - # prove enforcement would be a false green, so an unenforcing kernel - # fails the leg instead of skipping. - - name: Launcher tests (real kernel enforcement) - run: node ./test/launcher.test.js - env: - NALR_REQUIRE_LANDLOCK: 1 - - - name: Pack rehearsal (pack → install → confine, this platform only) - run: | - node ./scripts/pack-release.mjs .release/npm --current-platform-only - node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only - env: - NALR_REQUIRE_LANDLOCK: 1 - - darwin: - name: darwin (no platform package — degradation proof) - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - package_json_file: package.json - - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile - - - name: Build TypeScript - run: pnpm build:ts - - - name: Typecheck - run: pnpm typecheck - - - name: Entry tests (keyless) - run: node ./test/entry.test.js - - - name: Launcher tests (must self-skip cleanly) - run: node ./test/launcher.test.js - - - name: Pack rehearsal (entry only — fallback resolution + unusable probe) - run: | - node ./scripts/pack-release.mjs .release/npm --current-platform-only - node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/node-addon-system-release.yml similarity index 85% rename from .github/workflows/landlock-run-release.yml rename to .github/workflows/node-addon-system-release.yml index 76d08e9e4c..6c5596232b 100644 --- a/.github/workflows/landlock-run-release.yml +++ b/.github/workflows/node-addon-system-release.yml @@ -1,13 +1,13 @@ -# Build and publish the @deepseek-ai/node-addon-landlock-run package family from the +# Build and publish the @deepseek-ai/node-addon-system package family from the # harness source of record. Rehearsal and publication consume the same packed # tarballs; each native binary is built on its matching architecture. -name: Landlock Run Release +name: Node Addon System Release on: workflow_dispatch: inputs: publish: - description: Publish packed tarballs to npm. Must run from a landlock-run-v* tag. + description: Publish packed tarballs to npm. Must run from a node-addon-system-v* tag. required: true type: boolean default: false @@ -23,7 +23,7 @@ concurrency: defaults: run: - working-directory: native/landlock-run + working-directory: native/system jobs: matrix: @@ -58,9 +58,10 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-system-workspace... --frozen-lockfile - name: Install musl toolchain + if: runner.os == 'Linux' run: | sudo apt-get update -q sudo apt-get install -yq musl-tools @@ -71,11 +72,18 @@ jobs: - name: Verify binary metadata run: node ./scripts/verify-launcher-binary.mjs ${{ matrix.dir }} + - name: Verify the built entry and flock + run: | + pnpm build:ts + pnpm build:test-oracle + pnpm test:flock + pnpm test:packaging + - name: Upload prebuild artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} - path: native/landlock-run/${{ matrix.dir }}/bin/* + path: native/system/${{ matrix.dir }}/bin/** if-no-files-found: error retention-days: 7 @@ -97,7 +105,7 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install --filter @deepseek-ai/node-addon-landlock-run-workspace... --frozen-lockfile + run: pnpm install --filter @deepseek-ai/node-addon-system-workspace... --frozen-lockfile - name: Build TypeScript run: pnpm build:ts @@ -111,7 +119,7 @@ jobs: uses: actions/download-artifact@v4 with: pattern: prebuild-* - path: native/landlock-run/.release/prebuild-artifacts + path: native/system/.release/prebuild-artifacts - name: Assemble and verify prebuilds run: node ./scripts/assemble-prebuilds.mjs .release/prebuild-artifacts @@ -133,7 +141,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: npm-tarballs - path: native/landlock-run/dist/npm/* + path: native/system/dist/npm/* if-no-files-found: error retention-days: 7 @@ -162,7 +170,7 @@ jobs: uses: actions/download-artifact@v4 with: name: npm-tarballs - path: native/landlock-run/dist/npm + path: native/system/dist/npm - name: Configure npm token fallback env: diff --git a/.github/workflows/node-addon-system.yml b/.github/workflows/node-addon-system.yml new file mode 100644 index 0000000000..a763a2a8d2 --- /dev/null +++ b/.github/workflows/node-addon-system.yml @@ -0,0 +1,168 @@ +# CI for the node-addon-system packages under native/system. A separate +# workflow from ci.yml keeps the native OS/architecture matrix independent of +# the harness Node matrix. Each platform job builds once and tests those bytes +# under every supported Node release. Release assembly and publication use the +# companion Node Addon System Release workflow. +name: Node Addon System + +on: + pull_request: + paths: + - '.github/workflows/node-addon-system.yml' + - '.github/workflows/node-addon-system-release.yml' + - 'native/system/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + push: + branches: [master] + paths: + - '.github/workflows/node-addon-system.yml' + - '.github/workflows/node-addon-system-release.yml' + - 'native/system/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # CI runs must never report to the production telemetry endpoint baked + # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + +defaults: + run: + working-directory: native/system + +jobs: + matrix: + name: Matrix + runs-on: ubuntu-24.04 + outputs: + ci: ${{ steps.matrix.outputs.ci }} + steps: + - uses: actions/checkout@v4 + + - id: matrix + run: echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT" + + native: + name: ${{ matrix.platform }} + needs: matrix + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.ci) }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --filter @deepseek-ai/node-addon-system-workspace... --frozen-lockfile + + - name: Install musl toolchain + if: runner.os == 'Linux' + run: | + sudo apt-get update -q + sudo apt-get install -yq musl-tools + + - name: Build TypeScript + run: pnpm build:ts + + - name: Typecheck + run: pnpm typecheck + + - name: Build native binaries (this architecture is the builder of record) + run: pnpm build:native + + - name: Entry tests (keyless) + run: node ./test/entry.test.js + + # NALR_REQUIRE_LANDLOCK: a self-skip on the very platform that exists to + # prove enforcement would be a false green, so an unenforcing kernel + # fails the leg instead of skipping. + - name: Launcher tests (real kernel enforcement) + if: runner.os == 'Linux' + run: node ./test/launcher.test.js + env: + NALR_REQUIRE_LANDLOCK: 1 + + - name: Pack rehearsal (pack → install → confine, this platform only) + run: | + node ./scripts/pack-release.mjs .release/npm --current-platform-only + node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only + env: + NALR_REQUIRE_LANDLOCK: ${{ runner.os == 'Linux' && '1' || '0' }} + + - name: Verify platform payload rules (Node 24) + run: pnpm test:packaging + + - name: Flock behavior (Node 24, built addon) + run: | + pnpm build:test-oracle + pnpm test:flock + + - name: Test the same musl addon on Node 24 without a compiler + if: runner.os == 'Linux' + run: >- + docker run --rm -v "$PWD:$PWD" -w "$PWD" + node:24-alpine + node --test ./test/flock.test.js ./test/package-matrix.test.js + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Test the same binaries on Node 20 + run: node --test ./test/flock.test.js ./test/package-matrix.test.js + + - name: Test the same musl addon on Node 20 without a compiler + if: runner.os == 'Linux' + run: >- + docker run --rm -v "$PWD:$PWD" -w "$PWD" + node:20-alpine + node --test ./test/flock.test.js ./test/package-matrix.test.js + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Test the same binaries on Node 22 + run: node --test ./test/flock.test.js ./test/package-matrix.test.js + + - name: Test the same musl addon on Node 22 without a compiler + if: runner.os == 'Linux' + run: >- + docker run --rm -v "$PWD:$PWD" -w "$PWD" + node:22-alpine + node --test ./test/flock.test.js ./test/package-matrix.test.js + + - uses: actions/setup-node@v4 + with: + node-version: 26 + + - name: Test the same binaries on Node 26 + run: node --test ./test/flock.test.js ./test/package-matrix.test.js + + - name: Test the same musl addon on Node 26 without a compiler + if: runner.os == 'Linux' + run: >- + docker run --rm -v "$PWD:$PWD" -w "$PWD" + node:26-alpine + node --test ./test/flock.test.js ./test/package-matrix.test.js diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 143df14caa..cdee5ce196 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -76,8 +76,8 @@ jobs: # are optional, and building them needs a musl toolchain per architecture. - name: Pack the Landlock entry for verification run: | - pnpm --dir native/landlock-run run build:ts - pnpm --dir native/landlock-run/packages/entry pack --pack-destination "$PWD/dist/npm-landlock" + pnpm --dir native/system run build:ts + pnpm --dir native/system/packages/entry pack --pack-destination "$PWD/dist/npm-landlock" - name: Verify packed install run: pnpm run release:verify-packed-install --family dsh --from dist/npm --from dist/npm-vendor --from dist/npm-landlock diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e6662149e..1415d1f9e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,8 +166,8 @@ jobs: # are optional, and building them needs a musl toolchain per architecture. - name: Pack the Landlock entry for verification run: | - pnpm --dir native/landlock-run run build:ts - pnpm --dir native/landlock-run/packages/entry pack --pack-destination "$PWD/dist/npm-landlock" + pnpm --dir native/system run build:ts + pnpm --dir native/system/packages/entry pack --pack-destination "$PWD/dist/npm-landlock" - name: Verify packed install run: pnpm run release:verify-packed-install --family dsh --from dist/npm --from dist/npm-vendor --from dist/npm-landlock diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index d13e4f7c50..97b7e298d7 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -3,7 +3,7 @@ # .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md. # A separate workflow from ci.yml because the axis is different — these jobs # fan out over OS×runner (kernel capabilities), not node versions. The Landlock -# launcher is built from native/landlock-run on each Landlock leg and installed +# launcher is built from native/system on each Landlock leg and installed # from the same tarballs the main-repository release workflow publishes. name: Sandbox @@ -84,8 +84,8 @@ jobs: run: | sudo apt-get update -q sudo apt-get install -yq musl-tools - pnpm --dir native/landlock-run run build:ts - pnpm --dir native/landlock-run run build:native + pnpm --dir native/system run build:ts + pnpm --dir native/system run build:native # The unit suite runs on ubuntu in `checks`; this is the one darwin leg # in the workflow, so run it here too — the platform-dependent unit diff --git a/.github/workflows/weighted-approval-review-event.yml b/.github/workflows/weighted-approval-review-event.yml new file mode 100644 index 0000000000..403b8c14dd --- /dev/null +++ b/.github/workflows/weighted-approval-review-event.yml @@ -0,0 +1,19 @@ +name: weighted-approval-review-event +run-name: weighted-approval-review-event:${{ github.event.pull_request.number }} + +on: + pull_request_review: + types: [submitted, edited, dismissed] + +permissions: {} + +jobs: + record-review-event: + name: record weighted approval review event + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Record review event + run: | + echo 'This is by automated Angry Turtle Cyborg, not a human' + echo 'Recorded a weighted approval review event.' diff --git a/.github/workflows/weighted-approval.yml b/.github/workflows/weighted-approval.yml new file mode 100644 index 0000000000..ed57b60d8d --- /dev/null +++ b/.github/workflows/weighted-approval.yml @@ -0,0 +1,37 @@ +name: weighted-approval + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + workflow_run: + workflows: [weighted-approval-review-event] + types: [completed] + +permissions: + contents: read + pull-requests: read + statuses: write + +concurrency: + group: weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + publish-status: + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + name: publish weighted approval status + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # SECURITY: the status-writing job executes policy from the trusted default + # branch and reads pull-request reviews only as API data. + - name: Check out trusted approval policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Publish weighted approval status + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node .github/review-ownership/check-approval.mjs diff --git a/.gitignore b/.gitignore index a954fe5d85..fb999f93fb 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ apps/web/dist/ worktrees/ .agents/worktrees/ .typert-*/ +native/system/test/bin/ diff --git a/AGENTS.md b/AGENTS.md index e44772c1b2..a479e76625 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// support/ dev/test infrastructure util/ zero-dependency utilities python/ Python SDK/runtime (see python/README.md) -native/ @deepseek-ai/node-addon-landlock-run source of record (see native/README.md) +native/ @deepseek-ai/node-addon-system source of record (see native/README.md) benchmarks/ performance gates .agents/ Agent workflows and Agent Notes (`notes/`) docs/ architecture, generated catalogs, postmortems, cookbook (see docs/AGENTS.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 49c2d8ba69..31eebdab8d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. +External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. | Package | License | | --- | --- | @@ -55,7 +55,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | -| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | @@ -71,7 +70,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | -| [`fs-ext`](https://github.com/baudehlo/node-fs-ext) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | @@ -89,12 +87,12 @@ External packages that a workspace package resolves at runtime. The tier covers | [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | -| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`mime-types`](https://github.com/jshttp/mime-types) | MIT | | [`negotiator`](https://github.com/jshttp/negotiator) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`open`](https://github.com/sindresorhus/open) | MIT | +| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | @@ -123,23 +121,23 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.263 declares the following optional platform packages. Each carries the official Claude Code 2.1.263 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies -External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. +External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. | Package | License | | --- | --- | @@ -154,9 +152,9 @@ External packages **directly declared** only by repository tooling, test infrast | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | -| [`@types/fs-ext`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -192,6 +190,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT | | [`oxlint`](https://github.com/oxc-project/oxc) | MIT | | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | @@ -224,4 +223,4 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm ## First-party native packages -`@deepseek-ai/node-addon-landlock-run` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +`@deepseek-ai/node-addon-system` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. diff --git a/apps/cli/package.json b/apps/cli/package.json index c9807d7941..57084f2ac5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -76,6 +76,7 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-present": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 1c72034bf2..ae963e570a 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 42fb2855e97465417ca284944102d3c6a610416d -README.zh.md: 786774dae50a2dfcadbf3fd260b4680277c0d39e +README.md: 99ef8310cd1316e9ee4ade55fcac00766ae1e821 +README.zh.md: aa446d539a5b666e7745f459b5a014b22e72f90e diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 42fb2855e9..99ef8310cd 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -90,11 +90,11 @@ The production Web runner needs built package and frontend artifacts (`pnpm run Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. -The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its local filesystem and sandbox-policy root but intentionally omits instruction discovery and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. +The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root but intentionally omits filesystem tools, instruction discovery, and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. +`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only the platform-selected persistent shell. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 786774dae5..aa446d539a 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -92,11 +92,11 @@ dsh web --help 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 -基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为本地文件系统与沙箱策略根目录,但刻意省略指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。 +基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录,但刻意省略文件系统工具、指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合按平台选择的持久 shell。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ee1d6f3c04..ef606d49af 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1081,7 +1081,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['pty', '@deepseek-ai/dsh-terminal'], ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'], - ['fs-local', '@deepseek-ai/dsh-fs-local'], ['timer', '@deepseek-ai/cordis-plugin-timer'], ['llm', '@deepseek-ai/dsh-llm'], ['session', '@deepseek-ai/dsh-session'], @@ -1099,7 +1098,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['agent-loop', '@deepseek-ai/dsh-agent-loop'], ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], - ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], ]) expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl index 62b9632b9c..6cd9ea429c 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} @@ -6,20 +6,21 @@ {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Create a durable two-round goal","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Create a durable two-round goal","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"max_goal_rounds\":2}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"max_goal_rounds\":2}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"max_goal_rounds\":2}"}} {"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_get","name":"get_goal","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} @@ -30,7 +31,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL ROUND ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":3},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL ROUND ONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL ROUND ONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} @@ -39,7 +40,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":3,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}} {"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true,"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["partial"]}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":3,"step":1}} {"type":"turn/end","data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl index e518138271..5aa8425050 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} @@ -6,15 +6,16 @@ {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Create a durable goal for","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Create a durable goal for","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}} {"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":28,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":28,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} @@ -25,11 +26,11 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_complete","name":"update_goal","args":["{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}} {"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[29],"surfaceOp":"append"} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: Finish the ACP goal wrap-up snapshot proof"},"role":"user","id":"{{sessionId}}"}]}} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl index ec0e59992a..8d8ea28581 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl @@ -2,30 +2,31 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[7],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":15,"outputTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_probe","name":"update_goal","args":["{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":15,"time":0,"data":{"title":"Goal tool snapshot","messageSeqs":[7],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_probe"},"content":[{"type":"tool-result","toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[14],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":20,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"goal/change","seq":21,"time":0,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":25,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_get","name":"get_goal","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":26,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":29,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":15,"outputTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_probe","name":"update_goal","args":["{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Goal tool snapshot","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_probe"},"content":[{"type":"tool-result","toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"goal/change","seq":22,"time":0,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_get","name":"get_goal","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":27,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[27],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":33,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"GOAL READY","usage":{"inputTokens":100,"outputTokens":20}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index db6ca442a1..1f85f2ff2a 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} @@ -6,18 +6,19 @@ {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Prove the product headless profile","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"cli-mock","model":"cli-mock"}} -{"type":"session/title-llm-request","data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} +{"type":"session/title","data":{"title":"Prove the product headless profile","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"session/title-llm-request","data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Inspecting the task before the tool call."},{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":["Inspecting the task before the tool call."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Inspecting the task before the tool call."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"cli-smoke-call","name":"bash","args":["{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} +{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"tools":"{{tools}}"},"reason":"change"}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl index 4fdee7938b..cf4f395dd4 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl @@ -2,13 +2,14 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"say pong","messageSeqs":[7],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":13,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":15,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":14,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}} {"type":"result","sessionId":"{{sessionId}}","output":""} diff --git a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl index f5da288d50..b285bfab73 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl @@ -2,13 +2,14 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"say pong","messageSeqs":[7],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":13,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":15,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":14,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}} {"type":"result","sessionId":"{{sessionId}}","output":""} diff --git a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl index 422a11c55d..cb83ea205a 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl @@ -2,16 +2,17 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[7],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"retry the transient provider failure\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":13,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":14,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry-started","seq":15,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"retry":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RETRY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":18,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"retry the transient provider failure\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":14,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":15,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry-started","seq":16,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"retry":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RETRY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":19,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"RETRY_OK","usage":{"inputTokens":4,"outputTokens":2}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl index 3569347a12..e915da38b3 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl @@ -1,10 +1,11 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":1,"stream":[],"message":{"role":"assistant","content":[{"type":"tool-call","id":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-5","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-6","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[5]} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"interrupted"}}} {"type":"session/end-seed","data":{}} @@ -15,11 +16,12 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["I will verify the external state before deciding whether to retry the side-effecting operation."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl index d279ae9491..ac7306cfdd 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl @@ -1,6 +1,9 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Start a background job."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","data":{}} {"type":"permission/preset","data":{"preset":"workspace-write"}} @@ -10,14 +13,15 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The stored subagent is unreadable. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl index 4708037e04..70c1834115 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","isSeeded":false,"origin":"subagent","delegationDepth":1} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","isSeeded":false,"origin":"subagent","delegationDepth":1} {"type":"sandbox/mode","data":{"mode":"read-only","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} @@ -6,14 +6,15 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} {"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"child-write","name":"write","args":["{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["CHILD_DENIED [sandbox: file access denied under read-only mode]"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.replay.v3.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.replay.v3.jsonl new file mode 100644 index 0000000000..00dd06b51b --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.replay.v3.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":3,"id":"subagent-inheritance-child","createdAt":2,"isSeeded":false,"delegationDepth":1} +{"type":"turn/start","data":{"turn":1}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"child-system-head"}},"surfaceOp":"append"} +{"type":"user/message","data":{"role":"user","content":[{"type":"text","text":"delegated task"}],"source":{"kind":"user"},"id":"child-task"},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"child-write-message"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"child-write","name":"write","args":["{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"child-write-result"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[5],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"child-denied-message"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["CHILD_DENIED [sandbox: file access denied under read-only mode]"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index e4ea870189..44d31a36ab 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -1,8 +1,11 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"sandbox/mode","data":{"mode":"read-only"}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"}},"reason":"initial"}} +{"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","data":{}} {"type":"permission/preset","data":{"preset":"read-only"}} @@ -11,14 +14,16 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"resume"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Delegated write probe"}} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The delegated child was denied by the sandbox. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl index 9aca108580..520e96bbc0 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","isSeeded":false,"origin":"subagent","delegationDepth":1} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","isSeeded":false,"origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"sandbox/mode","data":{"mode":"workspace-write","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} @@ -6,11 +6,12 @@ {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index 8ef68eaf5c..b027378207 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -2,25 +2,27 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"Start one continuable background subagen","messageSeqs":[7],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":15,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[7],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":18,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":22,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":23,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":24,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":25,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":27,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable background subagen","messageSeqs":[8],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"PARENT_RECEIVED_CHILD_RESULT","usage":{"inputTokens":30,"outputTokens":15}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl index 649750ba5c..4e2b7ae5a5 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl @@ -1,7 +1,10 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nOld workspace instruction.\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","data":{}} {"type":"permission/preset","data":{"preset":"workspace-write"}} @@ -11,12 +14,13 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RESUME_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl index 2391364ecd..71d92dd690 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl @@ -1,7 +1,10 @@ -{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"turn/start","data":{"turn":1}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: CLAUDE.md\n\nOld CLAUDE rule.\n\nInstructions from: AGENTS.md\n\nOld AGENTS rule.\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"CLAUDE.md\",\"AGENTS.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"b525eb8a6d3660b732dad4b0aff1b7c63ab32890"},{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"3113bd093ae91976207dcef7390bdc0b2bfcfa10"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","data":{}} {"type":"permission/preset","data":{"preset":"workspace-write"}} @@ -11,12 +14,13 @@ {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} +{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RESUME_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 08ac6fdee7..48ea951be0 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -8,10 +8,9 @@ import { normalizeSessionSnapshot, normalizeSessionSnapshots, normalizeStdout, - scrubRequestHeaders, + scrubModelRequestBulk, type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' -import { prepareSessionEventNotificationsForComparison } from '@deepseek-ai/dsh-llm-replay' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { decompressZstdFrame, @@ -75,14 +74,14 @@ async function expectSessionSnapshot( expect(parseJsonl(normalizedActual ?? '')).toEqual(parseJsonl(normalizedExpected ?? '')) } -/** Compare current headless session-event wrappers with a committed v1 stream. */ +/** Compare the complete current-writer headless notification sequence. */ async function expectHeadlessStream(normalized: string, expectedPath: string): Promise { - const expected = prepareSessionEventNotificationsForComparison(await readFile(expectedPath, 'utf8')) + const expected = await readFile(expectedPath, 'utf8') expect(parseJsonl(normalized)).toEqual(parseJsonl(expected)) } /** Serve one deterministic DeepSeek-compatible response while retaining its request body. */ -async function deepseekDefaultsServer(): Promise { +async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean } = {}): Promise { const requests: JsonObject[] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { let body = '' @@ -93,9 +92,11 @@ async function deepseekDefaultsServer(): Promise { response.writeHead(200, { 'content-type': 'text/event-stream' }) let keepAlives = 3 const write = (): void => { - if (keepAlives-- > 0) { + // One-shot teardown may cancel background title work after the main response. + if (keepAlives-- > 0 + || (options.waitForTitleRequest === true && !requests.some(request => request.max_tokens === 64))) { response.write(': keep-alive\n\n') - setTimeout(write, 60) + timer = setTimeout(write, 60) return } response.end([ @@ -105,7 +106,8 @@ async function deepseekDefaultsServer(): Promise { '', ].join('\n\n')) } - setTimeout(write, 60) + let timer = setTimeout(write, 60) + response.once('close', () => { clearTimeout(timer) }) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -150,7 +152,7 @@ function normalizeHeadlessStream(rawStdout: string, cwd: string): string { } return record.event as JsonObject }) - const normalizedEvents = parseJsonl(scrubRequestHeaders(normalizeSessionLog( + const normalizedEvents = parseJsonl(scrubModelRequestBulk(normalizeSessionLog( `${events.map(event => JSON.stringify(event)).join('\n')}\n`, context, ))) @@ -497,8 +499,45 @@ describe('headless stream-json snapshots', () => { } }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('keeps the compatibility stream open until the title request arrives', async () => { + const server = await deepseekDefaultsServer({ waitForTitleRequest: true }) + try { + const response = await fetch(server.url, { + method: 'POST', + body: JSON.stringify({ max_tokens: 1024 }), + }) + const reader = response.body!.getReader() + try { + const decoder = new TextDecoder() + let body = '' + // Four heartbeats cross the ordinary fixture's three-heartbeat response. + while (body.split(': keep-alive\n\n').length < 5) { + const chunk = await reader.read() + expect(chunk.done).toBe(false) + body += decoder.decode(chunk.value) + expect(body).not.toContain('data:') + } + const title = await fetch(server.url, { + method: 'POST', + body: JSON.stringify({ max_tokens: 64 }), + }) + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + body += decoder.decode(chunk.value) + } + expect(body).toContain('data: [DONE]') + expect(await title.text()).toContain('data: [DONE]') + } finally { + await reader.cancel() + } + } finally { + await server.close() + } + }) + it('sends pi-ai DeepSeek compatibility through the one-shot app', async () => { - const server = await deepseekDefaultsServer() + const server = await deepseekDefaultsServer({ waitForTitleRequest: true }) try { const result = await runLoaderSmoke({ label: 'pi-ai DeepSeek compatibility headless stream-json snapshot', diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 2cb9a59e41..c666871584 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -30,7 +30,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' /** * With-key PTC mode proof: a real model receives only `run_code`, composes two * sub-calls, writes a file, and returns curated output while the log records - * each `tool/code-dispatch`. The keyless Loader smoke is in the sibling test. + * each `tool/ptc-dispatch`. The keyless Loader smoke is in the sibling test. */ const PERSONA = 'You are a coding agent. You work by writing TypeScript programs for run_code: ' @@ -380,7 +380,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr expect(calls.length).toBeGreaterThan(0) expect(calls.every(event => event.data.name === RUN_CODE_NAME)).toBe(true) // …and the program's tool calls landed as dispatch events under it. - const dispatches = events.filter(event => event.type === 'tool/code-dispatch') + const dispatches = events.filter(event => event.type === 'tool/ptc-dispatch') expect(dispatches.length).toBeGreaterThanOrEqual(2) expect(dispatches.every(event => event.data.name === 'bash')).toBe(true) const parents = new Set(calls.map(event => event.data.callId)) @@ -419,7 +419,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr await waitForIdle(ctx, handle.agent) const events: readonly SessionEvent[] = handle.agent.session.snapshotEvents() - const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') + const dispatch = events.find(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = await vi.waitFor(() => { const splice = handle.agent.session.snapshotEvents().findLast(event => event.type === 'agent/inbox/spliced' diff --git a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts index 9b0dd8496b..c5d679484e 100644 --- a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts @@ -9,7 +9,7 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage, ToolCallId , createMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, ToolCallId, createMessage } from '@deepseek-ai/dsh-llm' import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts' @@ -49,14 +49,19 @@ async function seedInterruptedSession(root: string, cwd: string): Promise { expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/'))) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('refuses unaudited V2 queued content before resume without publishing a successor', async () => { + let sourcePath = '' + let source = Buffer.alloc(0) + let sourceIdentity: { readonly dev: bigint; readonly ino: bigint } | undefined + const result = await runLoaderSmoke({ + label: 'unaudited V2 content resume refusal', + tempDirPrefix: 'dsh-format-guard-content-', + binScript, + libBinScript: binScript, + configPath, + binArgs: [configPath, 'Try to resume.'], + tsconfigPath, + env: { DSH_SNAPSHOT_FILE: replayFixture }, + expectedExitCode: 1, + prepare: async (runCwd) => { + sourcePath = generationLogPath(join(runCwd, '.sessions'), runCwd, sessionId, 2, 'none') + await mkdir(dirname(sourcePath), { recursive: true }) + const rows = [ + { type: 'session', version: 2, id: sessionId, createdAt: 1, cwd: runCwd, isSeeded: false, delegationDepth: 0 }, + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { type: 'team/message/queued', seq: 2, time: 3, data: { + version: 1, teamId: 'team', message: { + id: 'queued', senderId: 'sender', senderName: 'Sender', targetId: 'target', delivery: 'quiet', + content: [{ type: 'future-message-block', localSeq: 1 }], + }, + } }, + ] + source = Buffer.from(rows.map(row => JSON.stringify(row)).join('\n') + '\n') + await writeFile(sourcePath, source) + const identity = await stat(sourcePath, { bigint: true }) + sourceIdentity = { dev: identity.dev, ino: identity.ino } + }, + inspect: async () => { + expect(await readFile(sourcePath)).toEqual(source) + const identity = await stat(sourcePath, { bigint: true }) + expect({ dev: identity.dev, ino: identity.ino }).toEqual(sourceIdentity) + expect((await readdir(dirname(sourcePath))).filter(name => name !== 'session.lock')) + .toEqual(['session.v2.jsonl']) + }, + }) + expect(result.stderr).toContain( + 'format v2 team/message/queued at seq 2 data.message.content[0]: cannot safely transform unclassified message content kind "future-message-block"', + ) + expect(result.stderr).toContain('source v2 artifact remains unchanged') + expect(result.stderr).toContain(sourcePath.slice(sourcePath.indexOf('/.sessions/'))) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('refuses to resume a log with an unknown required event type', async () => { let sessionPath = '' const result = await runLoaderSmoke({ diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index f8c02f2e4b..7cdb9a2e36 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -15,7 +15,7 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' @@ -31,7 +31,7 @@ const childId = SessionId('subagent-diagnostic-child') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const task = 'Call list_agents once and report what it shows.' -/** Compare one current normalized Session with its generation-aware committed fixture. */ +/** Compare current normalized Session records without historical migration. */ async function expectSession(actual: string, expectedPath: string): Promise { const expected = await readFile(expectedPath, 'utf8') const parse = (content: string): Record[] => content.split('\n') @@ -59,8 +59,15 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise } const parentEvents: SessionEvent[] = [ { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } }, - { type: 'user/message', seq: SessionSeq(1), time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background job.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, - { type: 'turn/end', seq: SessionSeq(2), time: 12, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } }, + { + type: 'system/message', seq: SessionSeq(2), time: 12, + data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) }, + surfaceOp: 'append', + }, + { type: 'user/message', seq: SessionSeq(3), time: 13, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background job.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, + { type: 'step/end', seq: SessionSeq(4), time: 14, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(5), time: 15, data: { turn: 1, reason: { kind: 'completed' } } }, ] const childMeta: SessionHeader = { version: SESSION_FORMAT_VERSION, diff --git a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts index 113b570107..5317b3f347 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts @@ -14,14 +14,15 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' const fixtureDir = fileURLToPath(new URL('./expected/subagent-inheritance', import.meta.url)) const replayOverride = join(fixtureDir, 'replay.override.json') -const childReplay = join(fixtureDir, 'child.replay.jsonl') +// Native replay preserves the V0 script's model outputs without migrating its pre-step user event. +const childReplay = join(fixtureDir, 'child.replay.v3.jsonl') const parentExpected = join(fixtureDir, 'parent.expected.jsonl') const childExpected = join(fixtureDir, 'child.expected.jsonl') const configPath = fileURLToPath(new URL('../subagent-inheritance-snapshot.patch.yml', import.meta.url)) @@ -31,7 +32,7 @@ const sessionId = SessionId('subagent-inheritance-parent') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const task = 'Delegate the write probe to a subagent.' -/** Compare one current normalized Session with its generation-aware committed fixture. */ +/** Compare current normalized Session records without historical migration. */ async function expectSession(actual: string, expectedPath: string): Promise { const expected = await readFile(expectedPath, 'utf8') const parse = (content: string): Record[] => content.split('\n') @@ -55,12 +56,18 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise { } const events: SessionEvent[] = [ { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } }, - { type: 'user/message', seq: SessionSeq(1), time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, - { type: 'sandbox/mode', seq: SessionSeq(2), time: 12, data: { mode: 'read-only' } }, + { type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } }, + { + type: 'system/message', seq: SessionSeq(2), time: 12, + data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) }, + surfaceOp: 'append', + }, + { type: 'user/message', seq: SessionSeq(3), time: 13, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, + { type: 'sandbox/mode', seq: SessionSeq(4), time: 14, data: { mode: 'read-only' } }, { type: 'request/header', - seq: SessionSeq(3), - time: 13, + seq: SessionSeq(5), + time: 15, data: { header: { config: { @@ -72,7 +79,8 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise { reason: 'initial', }, }, - { type: 'turn/end', seq: SessionSeq(4), time: 14, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'step/end', seq: SessionSeq(6), time: 16, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 17, data: { turn: 1, reason: { kind: 'completed' } } }, ] try { const handle = await ctx.sessionPersistence.create(meta) diff --git a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts index 162cedc466..6fc5216298 100644 --- a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts @@ -15,7 +15,7 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId, @@ -87,17 +87,23 @@ async function seedVisibleBaseline( }) const events: SessionEvent[] = [ { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } }, + { type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } }, + { + type: 'system/message', seq: SessionSeq(2), time: 12, + data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) }, + surfaceOp: 'append', + }, { type: 'user/message', - seq: SessionSeq(1), - time: 11, + seq: SessionSeq(3), + time: 13, data: createUserMessage({ content: [{ type: 'text', text: 'Remember the workspace instruction.' }], source: { kind: 'user' } }), surfaceOp: 'append', }, { type: 'user/message', - seq: SessionSeq(2), - time: 12, + seq: SessionSeq(4), + time: 14, data: createUserMessage({ content: [{ type: 'text', text: baseline.text }], source: { @@ -115,7 +121,8 @@ async function seedVisibleBaseline( }), surfaceOp: 'append', }, - { type: 'turn/end', seq: SessionSeq(3), time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'step/end', seq: SessionSeq(5), time: 15, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(6), time: 16, data: { turn: 1, reason: { kind: 'completed' } } }, ] try { const handle = await ctx.sessionPersistence.create(meta) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 6db08281f3..c879e5b8b9 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -189,8 +189,25 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) - it('boots the standalone minimal profile through its generated manifest', async () => { + it.each([ + { label: 'boots the standalone minimal profile through its generated manifest', editorEnabled: false }, + { label: 'executes the documented editor opt-in patch with sdk-minimal', editorEnabled: true }, + ])('$label', async ({ editorEnabled }) => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) + const editorPatch = join(root, 'editor.patch.yml') + if (editorEnabled) { + const guide = await readFile(join(repoRoot, 'docs/user/guide/python-sdk.md'), 'utf8') + const yaml = guide.split('')[1] + ?.match(/```yaml\n([\s\S]*?)```/)?.[1] + expect(yaml).toBeDefined() + await writeFile(editorPatch, yaml!) + } + const editorFile = join(root, 'editor.txt') + const editorContent = 'sdk-minimal editor opt-in\n' + const editorCalls = editorEnabled ? [ + { command: 'create', path: editorFile, file_text: editorContent }, + { command: 'view', path: editorFile }, + ] : [] const modelRequests: Record[] = [] const modelServer = createServer((request, response) => { let body = '' @@ -200,8 +217,21 @@ describe('Python SDK dsh profile keyless smoke', () => { modelRequests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') - response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') - response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + const toolCall = editorCalls[modelRequests.length - 1] + if (toolCall) { + response.write(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index: 0, + id: `editor-${toolCall.command}`, + type: 'function', + function: { name: 'str_replace_editor', arguments: JSON.stringify(toolCall) }, + }] } }] })}\n\n`) + } else { + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + } + response.write(`data: ${JSON.stringify({ + choices: [{ delta: {}, finish_reason: toolCall ? 'tool_calls' : 'stop' }], + usage: { prompt_tokens: 3, completion_tokens: 1 }, + })}\n\n`) response.end('data: [DONE]\n\n') }) }) @@ -214,6 +244,7 @@ describe('Python SDK dsh profile keyless smoke', () => { binScript, '--profile', 'sdk-minimal', + ...(editorEnabled ? ['--patch', editorPatch] : []), ], { cwd: repoRoot, env: { @@ -251,11 +282,14 @@ describe('Python SDK dsh profile keyless smoke', () => { method: 'session/prompt', params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, })}\n`) - await waitForLine(lines, (value) => { + const turnEnd = await waitForLine(lines, (value) => { const params = value.params as Record | undefined const event = params?.event as Record | undefined return params?.sessionId === 'minimal' && event?.type === 'turn/end' }, () => stderr) + expect(turnEnd).toMatchObject({ + params: { event: { data: { reason: { kind: 'completed' } } } }, + }) const profile = JSON.parse( await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'), @@ -266,13 +300,27 @@ describe('Python SDK dsh profile keyless smoke', () => { }) expect(modelRequests[0]?.tools).toEqual(expect.any(Array)) const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] - expect(tools.map(tool => tool.function?.name).sort()).toEqual( - [process.platform === 'win32' ? 'pwsh' : 'bash', 'str_replace_editor'].sort(), - ) + expect(tools.map(tool => tool.function?.name)).toEqual([ + process.platform === 'win32' ? 'pwsh' : 'bash', + ...(editorEnabled ? ['str_replace_editor'] : []), + ]) + expect(modelRequests).toHaveLength(editorEnabled ? 3 : 1) + if (editorEnabled) { + expect(await readFile(editorFile, 'utf8')).toBe(editorContent) + expect(modelRequests[2]?.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + role: 'tool', + tool_call_id: 'editor-view', + content: expect.stringContaining(editorContent.trim()) as unknown, + }), + ])) + } child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) await waitForLine(lines, value => value.id === 3, () => stderr) const exit = await child + expect(exit.timedOut, stderr).toBe(false) + expect(exit.signal, stderr).toBeUndefined() expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) } finally { child.kill('SIGKILL') diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 3ac7af61ce..eacd602971 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -32,8 +32,7 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.' const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. -* You don't have access to the internet via this tool. -* You do have access to a mirror of common linux and python packages via apt and pip. +* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available. * State is persistent across command calls and discussions with the user. * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. * Please avoid commands that may produce a very large amount of output. @@ -63,6 +62,8 @@ async function bootWeb( // back on the next run, so a stored document from any other build decides // this test's boot. Same reason the settings row above is pinned. { id: 'storage-json', config: { root: storageRoot } }, + // Fixed Session IDs must stay inside this boot's temporary profile root. + { id: 'session-persistence-jsonl', config: { root: join(dirname(settingsFile), 'sessions') } }, // Host rows with side effects outside this process: a bound port, a served // asset tree, a telemetry exporter. `api-gateway` and `directory-picker` // stay ENABLED on purpose — the api-proxy is the host row that injects @@ -189,9 +190,7 @@ describe('the shipped Web composition', () => { it('leaves the global tool layer empty', () => { // Every model-facing tool belongs to a preset, `ask_user_question` // included: a tool in the global layer reaches EVERY agent regardless of - // which preset composed it, so a two-tool benchmark surface would really - // present three. A regression here means an agent-plane row came back to - // the host composition. + // which preset composed it, expanding that preset's tool list. expect(toolNames(ctx)).toEqual([]) }) @@ -243,7 +242,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill', + 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'ralph', 'read', 'read_image', 'send_message', 'skill', 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write', ]) @@ -287,7 +286,7 @@ describe('the shipped Web composition', () => { } }) - it('composes the exact RL prompt and two tools from `minimal`', async () => { + it('composes the exact RL prompt and persistent shell from `minimal`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-minimal'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), @@ -297,11 +296,13 @@ describe('the shipped Web composition', () => { expect(assembly.sections).toEqual([ { name: 'deployment:persona-prefix', text: MINIMAL_PROMPT }, ]) - expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor']) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash']) expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) - expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters)) - .toContain('Absolute path') expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() + // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs. + expect(ctx.agentPresets.serviceFor(handle.agent, 'fs')).toBeUndefined() + expect(ctx.get('fs')?.sandboxMode).toBeDefined() + expect(handle.agent.ctx.get('fs')?.sandboxMode).toBe(ctx.get('fs')?.sandboxMode) expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeUndefined() expect(handle.agent.ctx.get('compaction')).toBeUndefined() } finally { @@ -319,7 +320,7 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(ctx, minimal.agent)).toEqual(['bash']) expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) await minimal.dispose() @@ -470,7 +471,7 @@ describe('the shipped Web composition', () => { // stays the preset's choice — minimal mounts no `tool-skill`, so its // tool table has no loader even though the global layer is readable. expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash']) } finally { await handle.dispose() } @@ -849,7 +850,7 @@ describe('authoring a preset on the shipped composition', () => { try { // The same tools the shipped `minimal` composes, from a directory copied // through the service into a root outside the installed harness. - expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(authorCtx, handle.agent)).toEqual(['bash']) } finally { await handle.dispose() } @@ -884,9 +885,9 @@ describe('the default preset as a user setting', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), }) try { - // `mount()` with no id resolves the effective default. Two tools, not + // `mount()` with no id resolves the effective default. One tool, not // `standard`'s catalog: the setting decided the composition. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash']) } finally { await handle.dispose() } @@ -911,7 +912,7 @@ describe('a session keeps the preset it was created with', () => { try { // The api-proxy guard reads exactly this: the header records what the // session runs, so naming anything else is a caller error rather than a - // switch. Its history was produced under `minimal`'s two tools. + // switch. Its history was produced under `minimal`'s single tool. expect(handle.agent.session.header.agentPreset).toBe('minimal') } finally { await handle.dispose() @@ -973,7 +974,7 @@ describe('a composition that configures its own preset roots', () => { setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined), }) try { - expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash']) } finally { await handle.dispose() } diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 75222a8f43..fc44413c3d 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private upstream-Node host process for the Electron desktop application", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ec0b802a17..d4a8146a1c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for an isolated pnpm-installed dsh runtime", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 1f32123ec0..64855151d4 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -266,10 +266,10 @@ async function main(): Promise { await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT) rmSync(buildPaths.packedLandlock, { recursive: true, force: true }) mkdirSync(buildPaths.packedLandlock, { recursive: true }) - await runPnpm(['--dir', 'native/landlock-run', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) + await runPnpm(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) await runPnpm([ '--dir', - 'native/landlock-run/packages/entry', + 'native/system/packages/entry', 'pack', '--pack-destination', buildPaths.packedLandlock, diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index f5320b47bd..27ac24fce4 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -18,6 +18,7 @@ import type { DesktopRelease } from '../src/release.ts' import { archivePnpmStore } from '../src/seed-store.ts' const roots: string[] = [] +const releaseWorkers: Array<() => Promise> = [] function temporaryRoot(): string { const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-')) @@ -162,8 +163,13 @@ function release(version = '1.0.0'): DesktopRelease { } } -afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +afterEach(async () => { + const cleanups = releaseWorkers.splice(0) + const directories = roots.splice(0) + const results = await Promise.allSettled(cleanups.map(cleanup => cleanup())) + for (const root of directories) rmSync(root, { recursive: true, force: true }) + const failures: unknown[] = results.flatMap((result): unknown[] => result.status === 'rejected' ? [result.reason] : []) + if (failures.length > 0) throw new AggregateError(failures, 'desktop worker cleanup failed') }) describe('desktop package policy', () => { @@ -293,7 +299,7 @@ describe('desktop project transactions', () => { expect(existsSync(paths.pending)).toBe(false) }) - it('records the live pnpm worker as transaction owner until it exits', async () => { + it('records the live pnpm worker as transaction owner until it exits', async ({ task, signal }) => { const root = temporaryRoot() const seed = join(root, 'seed') const ready = join(root, 'pnpm-ready') @@ -306,7 +312,19 @@ describe('desktop project transactions', () => { const runtime = { node: process.execPath, pnpm: writeBlockingFakePnpm(root, ready, releaseWorker) } const manager = new DesktopProjectManager(paths, runtime) const installing = manager.applyRelease(seed, '1.0.0', hooks()) - await expect.poll(() => existsSync(ready)).toBe(true) + // Teardown observes failures even if the runner has abandoned the test body. + const completed = installing.then(value => ({ value }), (error: unknown) => ({ error })) + releaseWorkers.push(async () => { + writeFileSync(releaseWorker, 'continue') + const outcome = await completed + if ('error' in outcome) throw outcome.error + }) + // Child startup shares the test budget; an aborted poll must not resume ownership assertions. + await expect.poll(() => { + signal.throwIfAborted() + return existsSync(ready) + }, { timeout: task.timeout }).toBe(true) + signal.throwIfAborted() const workerPid = Number.parseInt(readFileSync(ready, 'utf8'), 10) expect(readFileSync(paths.lock, 'utf8')).toBe(`${String(workerPid)}\n`) const competing = new DesktopProjectManager(paths, runtime) diff --git a/apps/desktop/tests/seed-store.spec.ts b/apps/desktop/tests/seed-store.spec.ts index b2f5fc614a..e0e7d147c3 100644 --- a/apps/desktop/tests/seed-store.spec.ts +++ b/apps/desktop/tests/seed-store.spec.ts @@ -63,7 +63,7 @@ describe('desktop seed store cleanup', () => { }) describe('desktop seed store merge', () => { - it('preserves installed package records while the verified seed replaces matching records and files', () => { + it('preserves installed package records while the verified seed replaces matching records and files', { timeout: 30_000 }, () => { const root = temporaryRoot() const source = join(root, 'source') const destination = join(root, 'destination') diff --git a/apps/web/package.json b/apps/web/package.json index 6de8fd4428..6c6dbc31aa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index db33227043..fe7b5ec14e 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -150,7 +150,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') expect(metadata).toContain('name: 我的模式') - expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。') + expect(metadata).toContain('description: 仅提供持久 shell 的单工具编码 Agent。') expect(metadata).not.toContain('order:') }, 60_000) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 49e302c4c3..b153e29dff 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -19,6 +19,7 @@ import { SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId, } from '@deepseek-ai/dsh-session' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -81,7 +82,8 @@ async function seedWorkspaceSkill(workspaceCwd: string): Promise { /** * A settled one-turn session with no model content: this lane asserts chrome * around a conversation, not a conversation, and a recorded turn would tie - * the golden to a provider's wording for no gain. + * the golden to a provider's wording for no gain. Its empty system head + * belongs to the first step, before the user message. * @returns a tokenized session log ending on a closed turn. */ function seedLog(): string { @@ -89,9 +91,18 @@ function seedLog(): string { const at = (index: number, event: Record): string => JSON.stringify({ ...event, seq: index, time: time + index }) return [ - JSON.stringify({ type: 'session', version: 0, id: '{{sessionId}}', createdAt: time, cwd: '{{cwd}}/workspace' }), + JSON.stringify({ + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', + createdAt: time, cwd: '{{cwd}}/workspace', isSeeded: false, delegationDepth: 0, + }), at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }), - at(1, { + at(1, { type: 'step/start', data: { turn: 1, step: 1 } }), + at(2, { + type: 'system/message', + data: { turn: 1, step: 1, message: createSystemMessage('', '@deepseek-ai/dsh-system-prompt') }, + surfaceOp: 'append', + }), + at(3, { type: 'user/message', data: { id: '00000000-0000-4000-9000-000000000001', @@ -101,8 +112,9 @@ function seedLog(): string { }, surfaceOp: 'append', }), - at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }), - at(3, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }), + at(4, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [3], source: { kind: 'fallback' } } }), + at(5, { type: 'step/end', data: { turn: 1, step: 1 } }), + at(6, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') } @@ -138,10 +150,10 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise type: 'user/message', seq: 1, time: createdAt + 1, - data: { + data: createUserMessage({ content: [{ type: 'text', text: 'Check the session-header action order.' }], source: { kind: 'user' }, - }, + }), surfaceOp: 'append', }, { diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index 268549f58f..d1bc84a488 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -17,7 +17,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/approval-composer', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') // The golden covers the stable waiting panel; direct assertions cover its answer. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() @@ -142,6 +142,6 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { }, 300_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'ui.expected.md', 'workspace.expected']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md', 'workspace.expected']) }) }) diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index cb7025435f..f4db98483a 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -16,7 +16,7 @@ import { } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/background-job-list', import.meta.url)) const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts index 50fa548e63..ce42436859 100644 --- a/apps/web/tests/bash-abort-row.e2e.ts +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -13,7 +13,7 @@ import { } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/acp/cancel-tool-calls/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/acp/cancel-tool-calls/session.v3.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/bash-abort-row', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index d7ee3ae289..4299bf542c 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -16,6 +16,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, + parseSeedFixture, seedSession, watchConsole, webSnapshotMode, @@ -471,6 +472,29 @@ function assertClean(world: ScrollWorld): void { expect(world.tripwire.warnings).toEqual([]) } +it('generates a native V3 scroll seed with a protected system head and intact references', () => { + const { header, events } = parseSeedFixture(HISTORY_FIXTURE.log) + expect(header.version).toBe(3) + expect(events.slice(0, 5).map(event => event.type)).toEqual([ + 'turn/start', 'step/start', 'system/message', 'user/message', 'session/title', + ]) + expect(events.filter(event => event.type === 'system/message')).toHaveLength(1) + const firstUser = events.find(event => event.type === 'user/message')! + const title = events.find(event => event.type === 'session/title')! + expect(title.data.messageSeqs).toEqual([firstUser.seq]) + const calls = events.filter(event => event.type === 'tool/call') + const results = events.filter(event => event.type === 'tool/result') + expect(calls).toHaveLength(22) + expect(results).toHaveLength(calls.length) + for (const result of results) { + const call = calls.find(event => event.data.callId === result.data.message.source.callId)! + expect(result.sourceEventSeqs).toEqual([call.seq]) + expect(call.seq).toBeLessThan(result.seq) + } + expect(events.filter(event => event.type === 'turn/end')).toHaveLength(HISTORY_FIXTURE.turns) + expect(events.at(-1)?.type).toBe('turn/end') +}) + let browser: Browser describe('web e2e: long Chat scroll contract', () => { diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index 48784538f8..34ebcc8d1b 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -5,6 +5,7 @@ import { ToolCallId, createAssistantMessage, + createSystemMessage, createToolResultMessage, createUserMessage, } from '@deepseek-ai/dsh-llm' @@ -64,11 +65,21 @@ function markerHelpers(prefix: string): ChatScrollMarkers { } } +function appendSystemPrompt(session: Session, turn: number, step: number): void { + session.append('system/message', { + turn, + step, + message: createSystemMessage( + 'Synthetic chat-scroll system prompt.', + '@deepseek-ai/dsh-system-prompt', + ), + }, { surfaceOp: 'append' }) +} + function appendRequestHeader(session: Session, turn: number, step: number): void { session.append('request/header', { header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`, }, reason: turn === 1 && step === 1 ? 'initial' : 'change', }) @@ -188,6 +199,9 @@ export function createChatScrollFixture(options: ChatScrollFixtureOptions): Chat session.append('turn/start', { turn, }) + session.append('step/start', { turn, step: 1 }) + // Native V3 installs the protected system head before any user surface. + if (turn === 1) appendSystemPrompt(session, turn, 1) const user = session.append('user/message', createUserMessage({ content: text( `${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. ` @@ -203,7 +217,6 @@ export function createChatScrollFixture(options: ChatScrollFixtureOptions): Chat }) } - session.append('step/start', { turn, step: 1 }) appendRequestHeader(session, turn, 1) if (turn % TOOL_INTERVAL === 0) { appendToolStep(session, markers, turn) diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index e50a5bba5e..faa0146134 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -13,6 +13,7 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { ToolCallId, createAssistantMessage, + createSystemMessage, createToolResultMessage, createUserMessage, expandAssistantStream, @@ -196,11 +197,21 @@ function appendTitle(session: Session, title: string, messageSeq: SessionSeq): v }) } +function appendSystemPrompt(session: Session, turn: number, step: number): void { + session.append('system/message', { + turn, + step, + message: createSystemMessage( + 'Synthetic performance system prompt.', + '@deepseek-ai/dsh-system-prompt', + ), + }, { surfaceOp: 'append' }) +} + function appendRequestHeader(session: Session, turn: number, step: number): void { session.append('request/header', { header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - system: `Synthetic performance system prompt for turn ${String(turn)}, step ${String(step)}.`, }, reason: turn === 1 && step === 1 ? 'initial' : 'change', }) @@ -334,6 +345,7 @@ function smallSidebarFixture(): string { }), { surfaceOp: 'append' }) appendTitle(session, 'Synthetic sidebar session', user.seq) session.append('step/start', { turn: 1, step: 1 }) + appendSystemPrompt(session, 1, 1) appendRequestHeader(session, 1, 1) appendToolStep(session, 1, 1, 2) session.append('step/end', { turn: 1, step: 1 }) @@ -360,6 +372,7 @@ function longHistoryFixture(): string { if (turn === 1) appendTitle(session, LONG_SESSION_TITLE, user.seq) session.append('step/start', { turn, step: 1 }) + if (turn === 1) appendSystemPrompt(session, turn, 1) appendRequestHeader(session, turn, 1) if (turn % TOOL_TURN_INTERVAL === 0) { appendToolStep(session, turn, 1, TOOLS_PER_TOOL_TURN) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 2b7c75b547..1000bc0aaf 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -20,7 +20,7 @@ import { } from './scaffold.ts' import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() const CORDIS_TOOLS = ['cordis_inspect_self', 'cordis_define', 'cordis_run', 'cordis_stop'] as const diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 239b25f7ef..05fb15dc51 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -16,8 +16,8 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/details-sessi const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md') const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url)) -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v2.jsonl', import.meta.url)) -const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)) +const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' const MODE = webSnapshotMode() @@ -226,11 +226,15 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await page.locator('[data-sidebar-right-expand]').click() await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(1) await expect.poll(() => columns(page)).toEqual(normalColumns) + // The panel's slide completes independently of the frame's grid tracks. + await expect.poll(() => panel.evaluate(element => getComputedStyle(element).transform)) + .toBe('none') } const close = async (): Promise => { await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => column.locator('[data-sidebar-right-open]').count()).toBe(0) await expect.poll(() => detailsTrack(page)).toBe(0) + await panel.waitFor({ state: 'hidden' }) } await select(original, 'LIGHTHOUSE') @@ -239,8 +243,10 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S // CSS width assigned by the grid solver. await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 }) .toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth + 1, resizeHandleWidth: 8 }) - await column.locator('[data-sidebar-right-guide-entry="files"]').click() - await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 }) + await expect.poll(async () => ({ + filesVisible: await column.locator('[data-files-state="tree"]').isVisible(), + errors: tripwire.pageErrors, + })).toEqual({ filesVisible: true, errors: [] }) await column.locator('[data-dockkit-add-tab]').click() const split = column.locator('[data-dockkit-split-button]').first() await expect.poll(() => split.isDisabled()).toBe(false) @@ -249,7 +255,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1) const retainedA = await paneSnapshot(page) - expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Start']]) + expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Files']]) await checkpoint('A normal: two panes') await column.locator('[data-sidebar-right-mode="fullscreen"]').click() @@ -268,7 +274,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page)).toBe(0) await open() expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') - await column.locator('[data-sidebar-right-guide-entry="files"]').click() + await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 }) const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ }) await workspaceDirectory.waitFor({ timeout: 15_000 }) await workspaceDirectory.click() diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts new file mode 100644 index 0000000000..a8189f65de --- /dev/null +++ b/apps/web/tests/document-preview.e2e.ts @@ -0,0 +1,395 @@ +/** Keyless document-preview smoke through a real Session, Files tab, and shipped renderers. */ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { pdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' +import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url)) +const EXPECTED = join(SNAPSHOT_DIR, 'document.expected.md') +const PAGING_PATCH = join(SNAPSHOT_DIR, 'paging.patch.yml') +const PAGE_LINES = 64 +const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url)) +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' +const MODE = webSnapshotMode() + +/** Successful render evidence stays outside the committed snapshot inventory. */ +async function successShot(page: Page, name: string): Promise { + await mkdir(SHOT_DIR, { recursive: true }) + await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true }) +} + +/** Trigger the document owner's native scroll handler after a real first page overflows. */ +async function scrollForNextPage(body: Locator): Promise { + await body.evaluate((node) => { + if (node.scrollHeight <= node.clientHeight) throw new Error('paging fixture does not overflow the document body') + node.scrollTop = node.scrollHeight + }) +} + +/** Read the solid vector fill away from antialiased page edges. */ +async function canvasColor(canvas: Locator): Promise { + return await canvas.evaluate((node) => { + const surface = node as HTMLCanvasElement + const context = surface.getContext('2d') + if (context === null) throw new Error('PDF canvas has no 2D context') + const pixel = context.getImageData(Math.floor(surface.width / 2), Math.floor(surface.height / 2), 1, 1).data + if (Number(pixel[3]) !== 255) return 'transparent' + if (Number(pixel[0]) - Number(pixel[2]) > 150) return 'red' + if (Number(pixel[2]) - Number(pixel[0]) > 150) return 'blue' + return 'other' + }) +} + +describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let outsideRoot: string | undefined + + beforeAll(async () => { + outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-')) + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + try { + await browser?.close() + } finally { + try { + await scaffold?.close() + } finally { + if (outsideRoot !== undefined) await rm(outsideRoot, { recursive: true, force: true }) + } + } + }) + + it('opens Markdown, isolated HTML, and a rendered PDF from the Session workspace', async () => { + onTestFailed(async () => { + await mkdir(SHOT_DIR, { recursive: true }) + await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`) + }) + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd + if (cwd === undefined) throw new Error('settled Session has no workspace cwd') + if (outsideRoot === undefined) throw new Error('outside fixture directory is unavailable') + const outsideScript = join(outsideRoot, 'outside.js') + const outsideReference = relative(cwd, outsideScript).replace(/\\/g, '/') + const markdownText = [ + '# Markdown smoke', '', 'Rendered from the workspace.', '', + ...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(), + '# Markdown tail', + ].join('\n') + const codeLines = [ + ...Array.from({ length: PAGE_LINES }, (_, index) => index === 0 ? 'const prefix = "CODE_PREFIX";' : `// prefix line ${index + 1}`), + 'const tail = "CODE_TAIL";', + ] + await Promise.all([ + writeFile(join(cwd, 'smoke.md'), markdownText), + writeFile(join(cwd, 'pages.ts'), codeLines.join('\n')), + writeFile(join(cwd, 'notes.unknown'), 'UNKNOWN_SUFFIX\nPlain fallback.'), + writeFile(join(cwd, 'smoke.html'), [ + '', + '

HTML smoke

pending

pending

pending

', + '

pending

', + '', + '', + ``, + ].join('\n')), + writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_OK";'), + writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'), + writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'), + writeFile(join(cwd, 'smoke.pdf'), pdfFixture()), + ]) + + const column = page.locator('[data-rightbar-col]') + await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) + await column.locator('[data-files-reload]').click() + const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) + const addTab = column.locator('[data-dockkit-add-tab]') + expect(await column.locator('[data-dockkit-tab]').count()).toBe(1) + const defaultTitle = await filesTab.locator('[data-dockkit-tab-title]').innerText() + const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() + expect(initialFilesClose).toBe(0) + await filesTab.click({ button: 'right' }) + expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(0) + await addTab.waitFor({ state: 'visible' }) + const initialAdd = await addTab.count() + expect(initialAdd).toBe(1) + await addTab.click() + await column.locator('[data-sidebar-right-guide]').waitFor({ state: 'visible' }) + await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(2) + const guideTab = column.locator('[data-dockkit-tab]').filter({ hasNot: page.getByText('Files', { exact: true }) }) + const guideClose = await guideTab.locator('[data-dockkit-tab-close]').count() + const filesCloseWithGuide = await filesTab.locator('[data-dockkit-tab-close]').count() + expect(guideClose).toBe(1) + expect(filesCloseWithGuide).toBe(0) + await expect.poll(() => addTab.count()).toBe(0) + const addWithGuide = await addTab.count() + await guideTab.locator('[data-dockkit-tab-close]').click() + await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) + await expect.poll(() => column.locator('[data-sidebar-right-guide]').count()).toBe(0) + await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(1) + await addTab.waitFor({ state: 'visible' }) + const restoredFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() + const restoredAdd = await addTab.count() + expect(restoredFilesClose).toBe(0) + expect(restoredAdd).toBe(1) + const preview = column.locator('[data-document-preview]') + const openFile = async (name: string): Promise => { + await filesTab.click() + await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click() + await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true) + } + const viewer = preview.locator('[data-document-viewer-menu]') + const body = preview.locator('[data-textpreview-body]') + const sections = ['# Document preview'] + sections.push([ + '## Sidebar tabs', '', + `- Default tab: ${defaultTitle}`, + `- Files close buttons (alone -> with guide -> restored): ${[initialFilesClose, filesCloseWithGuide, restoredFilesClose].join(' -> ')}`, + `- Manual guide close buttons: ${guideClose}`, + `- Add buttons (Files -> guide -> Files): ${[initialAdd, addWithGuide, restoredAdd].join(' -> ')}`, + ].join('\n')) + + await openFile('smoke.md') + await expect.poll(() => viewer.innerText()).toBe('Markdown') + await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).waitFor({ timeout: 15_000 }) + expect(await preview.getByText('Rendered from the workspace.', { exact: true }).isVisible()).toBe(true) + const heading = await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).innerText() + const markdownTail = preview.getByRole('heading', { name: 'Markdown tail', exact: true }) + await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true) + expect(await markdownTail.count()).toBe(0) + await scrollForNextPage(body) + await markdownTail.waitFor({ timeout: 15_000 }) + await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0) + expect(await preview.getByRole('heading', { name: heading, exact: true }).count()).toBe(1) + expect(await preview.getByText('Rendered from the workspace.', { exact: true }).count()).toBe(1) + const tailHeading = await markdownTail.innerText() + await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded() + await successShot(page, 'markdown') + const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) }) + const markdownTabId = await markdownTab.getAttribute('data-dockkit-tab') + expect(markdownTabId).not.toBeNull() + const tabCount = await column.locator('[data-dockkit-tab]').count() + const markdownViewers = [await viewer.innerText()] + const sourceFonts: Array<{ fontSize: string; lineHeight: string }> = [] + for (const label of ['Code', 'Plain text']) { + await viewer.click() + await page.getByRole('menuitem', { name: label, exact: true }).click() + await expect.poll(() => viewer.innerText()).toBe(label) + if (label === 'Code') { + await preview.locator('.shiki').waitFor({ timeout: 15_000 }) + expect(await preview.locator('.shiki').textContent()).toBe(markdownText) + } else { + await expect.poll(async () => (await preview.locator('[data-textpreview-line]').first().textContent())?.trim()).toBe('# Markdown smoke') + } + const source = label === 'Code' ? preview.locator('.shiki') : preview.locator('[data-textpreview-line]').first() + sourceFonts.push(await source.evaluate((node) => { + const style = getComputedStyle(node) + return { fontSize: style.fontSize, lineHeight: style.lineHeight } + })) + expect(await markdownTab.getAttribute('data-dockkit-tab')).toBe(markdownTabId) + expect(await column.locator('[data-dockkit-tab]').count()).toBe(tabCount) + markdownViewers.push(await viewer.innerText()) + } + expect(sourceFonts[1]).toEqual(sourceFonts[0]) + sections.push([ + '## Markdown', '', + `- Heading: ${heading}`, + `- Tail loaded by scrolling: ${tailHeading}`, + `- Viewers: ${markdownViewers.join(' -> ')}`, + `- Same tab: ${String(await markdownTab.getAttribute('data-dockkit-tab') === markdownTabId)}`, + ].join('\n')) + + await openFile('smoke.html') + await expect.poll(() => viewer.innerText()).toBe('HTML') + const iframe = preview.locator('[data-html-preview]') + await iframe.waitFor({ timeout: 15_000 }) + expect(await iframe.getAttribute('sandbox')).toBe('allow-scripts') + const html = page.frameLocator('[data-html-preview]') + await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK') + await expect.poll(() => html.locator('#local-result').innerText()).toBe('LOCAL_JS_OK') + await expect.poll(() => html.locator('#outside-result').innerText()).toBe('OUTSIDE_JS_OK') + await expect.poll(() => html.locator('#local-result').evaluate(node => getComputedStyle(node).color)).toBe('rgb(12, 34, 56)') + await expect.poll(() => html.locator('#parent-result').innerText()).toBe('parent-blocked') + expect(await html.locator('#parent-result').getAttribute('data-error')).toBe('SecurityError') + expect(await page.locator('html').getAttribute('data-document-preview-escape')).toBeNull() + await successShot(page, 'html') + sections.push([ + '## HTML', '', + `- Viewer: ${await viewer.innerText()}`, + `- Sandbox: ${await iframe.getAttribute('sandbox')}`, + `- Inline script: ${await html.locator('#result').innerText()}`, + `- Local script: ${await html.locator('#local-result').innerText()}`, + `- Outside-workspace script: ${await html.locator('#outside-result').innerText()}`, + `- Local stylesheet: ${await html.locator('#local-result').evaluate(node => getComputedStyle(node).color)}`, + `- Parent access: ${await html.locator('#parent-result').innerText()} (${await html.locator('#parent-result').getAttribute('data-error')})`, + `- Parent unchanged: ${String(await page.locator('html').getAttribute('data-document-preview-escape') === null)}`, + ].join('\n')) + + await openFile('smoke.pdf') + await expect.poll(() => viewer.innerText()).toBe('PDF') + const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true }) + await canvas.waitFor({ state: 'visible', timeout: 30_000 }) + expect(await preview.locator('[role="toolbar"]').count()).toBe(0) + expect(await preview.locator('[data-pdf-page]').count()).toBe(2) + await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red') + const firstColor = await canvasColor(canvas) + expect(firstColor).toBe('red') + const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name))) + expect(workerNames).toContain('dsh-pdf') + await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded() + const secondPage = preview.getByRole('img', { name: 'PDF page 2', exact: true }) + await secondPage.waitFor({ state: 'visible', timeout: 30_000 }) + await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue') + const secondColor = await canvasColor(secondPage) + expect(secondColor).toBe('blue') + expect(await body.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true) + const pdfTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.pdf', { exact: true }) }) + const pdfTabId = await pdfTab.getAttribute('data-dockkit-tab') + expect(pdfTabId).not.toBeNull() + await filesTab.click() + await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) + await pdfTab.click() + await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded() + await secondPage.waitFor({ state: 'visible', timeout: 30_000 }) + await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue') + const restoredColor = await canvasColor(secondPage) + expect(restoredColor).toBe('blue') + expect(await pdfTab.getAttribute('data-dockkit-tab')).toBe(pdfTabId) + await successShot(page, 'pdf') + sections.push([ + '## PDF', '', + `- Viewer: ${await viewer.innerText()}`, + `- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`, + `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`, + `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`, + `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`, + `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`, + ].join('\n')) + + const releaseRead = Promise.withResolvers() + let waitingForRead = false + const readPage = scaffold.ctx.workspaceFiles.read.bind(scaffold.ctx.workspaceFiles) + const heldRead = vi.spyOn(scaffold.ctx.workspaceFiles, 'read').mockImplementation(async (agent, path, range, signal) => { + if (path === 'pages.ts' && (range.offset ?? 1) === 1) { + waitingForRead = true + await releaseRead.promise + } + return readPage(agent, path, range, signal) + }) + let initialReading = false + try { + await openFile('pages.ts') + await expect.poll(() => waitingForRead).toBe(true) + const reading = preview.locator('[data-document-loading]') + initialReading = await reading.isVisible() + expect(initialReading).toBe(true) + expect(await preview.locator('[data-code-preview]').count()).toBe(0) + const indicator = await reading.boundingBox() + const scroller = await body.boundingBox() + if (indicator === null || scroller === null) throw new Error('reading indicator or document body is not rendered') + expect(indicator.y).toBeGreaterThanOrEqual(scroller.y) + expect(indicator.y + indicator.height).toBeLessThanOrEqual(scroller.y + scroller.height) + await successShot(page, 'code-reading') + } finally { + releaseRead.resolve(undefined) + heldRead.mockRestore() + } + await expect.poll(() => viewer.innerText()).toBe('Code') + const highlightedLines = preview.locator('.shiki .line') + await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(PAGE_LINES) + const codeBlock = preview.locator('.md-code-block') + expect(await codeBlock.getAttribute('data-line-numbers')).toBe('true') + await expect.poll(() => highlightedLines.first().evaluate(node => getComputedStyle(node, '::before').content)) + .not.toMatch(/^(?:none|normal)$/u) + const numbering = await highlightedLines.first().evaluate((node) => { + const line = getComputedStyle(node) + const before = getComputedStyle(node, '::before') + return { + counterIncrement: line.counterIncrement, + gutterWidth: Number.parseFloat(before.width), + sourceInset: Number.parseFloat(line.paddingInlineStart), + } + }) + expect(numbering.counterIncrement).toBe('source-line 1') + expect(numbering.gutterWidth).toBeGreaterThan(0) + expect(numbering.sourceInset).toBeGreaterThan(numbering.gutterWidth) + const prefix = await highlightedLines.allTextContents() + expect(prefix).toEqual(codeLines.slice(0, PAGE_LINES)) + await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true) + await scrollForNextPage(body) + await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(codeLines.length) + const completed = await highlightedLines.allTextContents() + expect(completed).toEqual(codeLines) + await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0) + const scrollTop = await body.evaluate((node) => { + const target = Math.floor((node.scrollHeight - node.clientHeight) / 2) + if (target <= 0) throw new Error('code fixture does not overflow the document body') + node.scrollTop = target + return target + }) + await expect.poll(() => body.evaluate((node) => { + const banner = node.querySelector('.md-code-block')?.firstElementChild + const firstLine = node.querySelector('.shiki .line') + if (!(banner instanceof HTMLElement) || firstLine === null) throw new Error('missing rendered code banner or source line') + const bounds = node.getBoundingClientRect() + const clipTop = bounds.top + node.clientTop + const bannerBounds = banner.getBoundingClientRect() + const hit = document.elementFromPoint(bounds.left + node.clientLeft + node.clientWidth / 2, clipTop + 1) + return { + scrollTop: node.scrollTop, + position: getComputedStyle(banner).position, + topGap: bannerBounds.top - clipTop, + firstLineAbove: firstLine.getBoundingClientRect().top < clipTop, + topCoveredByBanner: hit !== null && banner.contains(hit), + } + })).toEqual({ scrollTop, position: 'sticky', topGap: 0, firstLineAbove: true, topCoveredByBanner: true }) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + await page.evaluate(() => navigator.clipboard.writeText('')) + await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n')) + sections.push([ + '## Code paging', '', + `- Viewer: ${await viewer.innerText()}`, + `- Initial reading indicator: ${initialReading}`, + `- Lines: ${prefix.length} -> ${completed.length}`, + `- Prefix retained: ${String(JSON.stringify(completed.slice(0, prefix.length)) === JSON.stringify(prefix))}`, + `- Tail: ${completed.at(-1)}`, + ].join('\n')) + + await openFile('notes.unknown') + await expect.poll(() => viewer.innerText()).toBe('Plain text') + const plainLines = preview.locator('[data-textpreview-line]') + await expect.poll(() => plainLines.count()).toBe(2) + const fallback = (await plainLines.allTextContents()).map(line => line.trim()) + expect(fallback).toEqual(['UNKNOWN_SUFFIX', 'Plain fallback.']) + sections.push(['## Unknown suffix', '', `- Viewer: ${await viewer.innerText()}`, `- Text: ${fallback.join(' | ')}`].join('\n')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) + await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml']) + }) +}) diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index 37dc43d9a6..f9daab0c26 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -43,7 +43,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 + - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img @@ -65,7 +65,7 @@ - list: - listitem: - 'button "设为默认: 我的模式"': - - text: 我的模式 自定义 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 + - text: 我的模式 自定义 仅提供持久 shell 的单工具编码 Agent。 - code: my-agent - 'button "查看路径: 我的模式"': - img diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index 7f8537f392..cfae761ba3 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -43,7 +43,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 + - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index e6c83143d7..7efe280eb5 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -43,7 +43,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 + - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index c89bfd50a5..7c05a3332a 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -3,6 +3,6 @@ - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program." - - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor." + - menuitem "Minimal mode Single-tool coding agent with a persistent shell." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." - menuitem "Refusing mode Resolves, then refuses to start." diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md new file mode 100644 index 0000000000..6a1a3e0511 --- /dev/null +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -0,0 +1,42 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "插件": + - img + - text: 插件 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: openrouter + - button "编辑 openrouter": 编辑 + - button "删除 openrouter": 删除 + - alert: "llm-pi-ai: provider \"openrouter\" model \"111\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks" + - listitem: + - text: zai + - button "编辑 zai": 编辑 + - button "删除 zai": 删除 + - listitem: + - text: acme-gateway 自定义 + - button "编辑 acme-gateway": 编辑 + - button "删除 acme-gateway": 删除 + - alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks" + - button "添加提供方": + - img + - text: 添加提供方 + - button "添加自定义提供方": + - img + - text: 添加自定义提供方 diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index e531e133a4..a2946223dc 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -18,7 +18,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-command', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md') const MODE = webSnapshotMode() @@ -98,7 +98,7 @@ describe('web e2e: /feedback command acknowledgement', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', 'ack.expected.md', 'ack-expanded.expected.md', + 'session.v3.jsonl', 'ack.expected.md', 'ack-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index d6df51d270..58ffcd0469 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -19,7 +19,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-release', import.meta.url)) // Both routes borrow the same settled turn; this manifest references its owner. -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.v3.jsonl', import.meta.url)) const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md') const RELEASE_EXPECTED = join(SNAPSHOT_DIR, 'feedback-release.expected.json') @@ -110,7 +110,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', { id: 'feedback-mock', name: 'Feedback mock', contextWindow: 128_000 }, ] }, ], - // The replayed session.v2.jsonl belongs to the feedback-command scenario; + // The replayed session.v3.jsonl belongs to the feedback-command scenario; // comparing (or refreshing) the persisted session here would rewrite // that shared source with this lane's feedback events. Persistence and // collector assertions belong to this lane. diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index 409c46ca6c..d39babbf16 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -6,7 +6,7 @@ // content-addressed store makes the saved path identical across record and // replay once the workspace cwd is tokenized, so the recorded read arguments // replay verbatim against a freshly re-uploaded object. -// Record: DSH_SNAPSHOT=record rewrites session.v2.jsonl, then a keyless +// Record: DSH_SNAPSHOT=record rewrites session.v3.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -21,7 +21,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-round', import.meta.url)) -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url)) const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url)) const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url)) @@ -277,7 +277,7 @@ describe('web e2e: generic file upload through the real assembly', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', + 'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', ]) }) }) diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index 9c44a90e04..fe9b498822 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -139,11 +139,22 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => { head: { ref: 'fix-session-replay', sha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }, }, } - expect((await send(webhookOrigin, 'ready', payload)).status).toBe(202) - // HTTP 202 acknowledges ingress; the model request follows durable - // Workspace and Session creation by the asynchronous webhook rule. - await expect.poll(() => adapter.requests.length, { timeout: 30_000 }).toBe(1) + const completed = Promise.withResolvers() + let reviewSession: string | undefined + const off = scaffold.ctx.on('session/event', (session, event) => { + if (event.type === 'user/message' && event.data.source.kind === 'webhook' + && event.data.source.provider === 'github' && event.data.source.source === 'primary-github' + && event.data.source.deliveryId === 'ready' && event.data.source.ruleId === 'review-pr-when-ready') reviewSession = session.id + if (event.type === 'turn/end' && session.id === reviewSession) completed.resolve(undefined) + }) + try { + expect((await send(webhookOrigin, 'ready', payload)).status).toBe(202) + await completed.promise + } finally { + off() + } expect(scaffold.ctx.agents.list()).toHaveLength(before + 1) + expect(adapter.requests).toHaveLength(1) const agent = scaffold.ctx.agents.list().find(candidate => candidate.session.header.cwd === scaffold.workspaceCwd) expect(agent).toBeDefined() diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 4259e71ab9..9b6ebf857f 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -17,7 +17,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/goal-multi-turn-actions', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md') @@ -178,7 +178,7 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'replay.override.json', 'session.v2.jsonl', 'ui.expected.md', 'ui-expanded.expected.md', + 'replay.override.json', 'session.v3.jsonl', 'ui.expected.md', 'ui-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index c189c982a7..f42337be37 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, @@ -27,7 +27,7 @@ import { } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') @@ -72,10 +72,17 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher')) + const input = page.locator('[data-composer-input]').first() + onTestFinished(async () => { + await input.press('Escape') + await writeComposerDraft(page, input, '') + await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' }) + }) const launcher = page.getByRole('button', { name: 'Commands' }) await launcher.click() const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) - await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) + await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE) expect(snapshot).toContain('text: Commands') @@ -84,9 +91,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const launchedBox = await menu.boundingBox() await page.locator('[data-composer-input]').first().press('Escape') await expect.poll(() => menu.count()).toBe(0) - const input = page.locator('[data-composer-input]').first() await writeComposerDraft(page, input, '/') - await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) + await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const typedBox = await menu.boundingBox() expect(launchedBox).not.toBeNull() expect(typedBox).not.toBeNull() @@ -114,7 +121,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const launcher = zhPage.getByRole('button', { name: '指令' }) await launcher.click() const menu = zhPage.getByRole('listbox', { name: '触发候选建议' }) - await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) + await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE) expect(zhTripwire.pageErrors).toEqual([]) @@ -205,6 +213,17 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const observeTurn = async () => { const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 }) + const observedReasoning = Promise.withResolvers() + const releaseStream = MODE === 'record' ? undefined : scaffold.ctx.on('llm/stream', async function* (_options, next) { + let reasoning = false + for await (const chunk of next()) { + if (reasoning && chunk.type !== 'reasoning-delta') { + await observedReasoning.promise + } + if (chunk.type === 'reasoning-delta') reasoning = true + yield chunk + } + }) try { await input.press('Enter') if (MODE !== 'record') { @@ -220,8 +239,11 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () }) }, { timeout: 10_000, interval: 10 }).toBe(true) } + observedReasoning.resolve(undefined) return await settled } finally { + observedReasoning.resolve(undefined) + releaseStream?.() if (MODE !== 'record') await page.setViewportSize(originalViewport) } } @@ -443,7 +465,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', 'replay.override.json', 'command-menu.expected.md', + 'session.v3.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'command-menu-zh.expected.md', 'connection-error.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', 'reloaded-expanded.expected.md', diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index a72723de46..fa69b3644e 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -30,7 +30,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/live-interactions', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') // One golden pins the empty mid-turn loading state, one pins the sendable draft // state, and the other four capture what remains after cancel, after a // non-retryable failure, after retry recovery, and after retry exhaustion. @@ -99,6 +99,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { } scaffold = await launchWebScaffold({ replayFixture: FIXTURE, + // Throughput snapshots need a nonzero interval between replayed chunks. + paceMs: 1, ...(overridePath === undefined ? {} : { replayOverride: overridePath }), ...(overridePath === undefined ? {} : { compareReplaySession: false }), ...(retryPolicy === undefined ? {} : { replayRetryPolicy: retryPolicy }), @@ -332,7 +334,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md', + 'session.v3.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md', 'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md', 'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md', ]) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 682aafd7b4..09f980ab5c 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -197,13 +197,24 @@ interface TableStop { } /** - * Wait for the right column to sit at its rail. Expanded, it would pin the - * transcript to exactly the message column and every breakout relation would - * go vacuous; the frame's collapse marker is the settled signal. + * Wait for collapsed columns, completed grid transitions, and the + * ConversationRoot ResizeObserver's width publication before measuring tables. * @param target - the page whose frame to read. */ -async function awaitRightRail(target: Page): Promise { - await target.waitForSelector('[data-rightbar-collapsed]', { timeout: 5_000 }) +async function awaitTableLayout(target: Page): Promise { + await target.evaluate(async () => { await document.fonts.ready }) + await target.waitForFunction(() => { + const element = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]') + if (element === null) return false + const tracks = getComputedStyle(element).gridTemplateColumns.split(' ').map(Number.parseFloat) + const root = element.querySelector('div[data-phase]') + // Mirrored from ui-layout's SIDEBAR_COLLAPSED; these tests use the Host compiler face. + return tracks[0] === 56 && tracks.at(-1) === 0 + && element.getAnimations().every(animation => + animation.playState === 'finished' || animation.playState === 'idle') + && root !== null + && root.style.getPropertyValue('--dsh-conversation-column-width') === `${String(root.offsetWidth)}px` + }, undefined, { timeout: 10_000 }) } /** @@ -268,7 +279,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro // viewport identically on every platform, which is what keeps one // committed golden true for all lanes. await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() - await awaitRightRail(page) + await awaitTableLayout(page) }, 180_000) afterAll(async () => { @@ -285,15 +296,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro */ const settleAt = async (width: number): Promise => { await page.setViewportSize({ width, height: 900 }) - // The wide wrapper follows the transcript width (the fill wrapper caps - // at the message column and would report "settled" mid-transition). - let previousWidth = -1 - await expect.poll(async () => { - const current = (await readTables(page))[1]!.clientWidth - const settled = current === previousWidth - previousWidth = current - return settled - }, { timeout: 10_000 }).toBe(true) + await awaitTableLayout(page) return readTables(page) } @@ -425,17 +428,8 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro await sessionRow.click() await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 }) await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() - await awaitRightRail(hidpiPage) - // The pane collapses ease over the layout transition: compare only a - // settled reading (two consecutive equal wide-wrapper widths). - let readings: TableReading[] = [] - let previousWide = -1 - await expect.poll(async () => { - readings = await readTables(hidpiPage) - const settled = readings[1]!.clientWidth === previousWide - previousWide = readings[1]!.clientWidth - return settled - }, { timeout: 10_000 }).toBe(true) + await awaitTableLayout(hidpiPage) + const readings = await readTables(hidpiPage) const baseline = (await sweep()).find(stop => stop.width === 1100)! const relations = (tables: TableReading[]) => tables.map(table => ({ marker: table.marker, diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 2e7553f65f..5036683851 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -19,7 +19,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actions', import.meta.url)) // Borrowed read-only: this scenario needs any settled user+assistant pair, not // a new recording (workspace-management / sidebar-scrollbar pattern). -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md') const MODE = webSnapshotMode() @@ -184,7 +184,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll( () => page.getByRole('button', { name: 'System prompt', exact: true }).count(), { timeout: 10_000 }, - ).toBe(1) + ).toBe(2) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until // hover/focus-within). Branch renders only under assistant answers — user @@ -233,6 +233,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => { () => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }, ).toBe(1) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').textContent(), + { timeout: 10_000 }, + ).toContain('Use the read tool twice (1)') // The row action owns a distinct ui-workspace injection from the message // action above, so exercise both through the loaded app before capture. const sourceRow = page.locator('[role="treeitem"][aria-selected="true"]') diff --git a/apps/web/tests/message-feedback-layout.e2e.ts b/apps/web/tests/message-feedback-layout.e2e.ts index 0f05b722d1..1d3ee180af 100644 --- a/apps/web/tests/message-feedback-layout.e2e.ts +++ b/apps/web/tests/message-feedback-layout.e2e.ts @@ -41,7 +41,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-feedb const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const MODE = webSnapshotMode() /** Borrowed read-only: this scenario needs any settled assistant message to rate. */ -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const SEED_ID = 'message-feedback-layout-e2e' /** Viewport widths from full-screen desktop down to a narrow window. */ const WIDTHS = [1680, 1280, 1024, 900, 700, 600] diff --git a/apps/web/tests/message-feedback-protocol.snapshot.ts b/apps/web/tests/message-feedback-protocol.snapshot.ts index e18a32b12d..6936479b61 100644 --- a/apps/web/tests/message-feedback-protocol.snapshot.ts +++ b/apps/web/tests/message-feedback-protocol.snapshot.ts @@ -12,7 +12,7 @@ import { } from './scaffold.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol', import.meta.url)) -const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json') const SESSION_ID = 'message-feedback-protocol' const MESSAGE_ID = fixtureIdentity('message', 2) @@ -112,6 +112,6 @@ describe('message feedback Host Remote protocol', () => { expect(exchanges.every(exchange => exchange.status === 200)).toBe(true) await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode) - await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.v2.jsonl']) + await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.v3.jsonl']) }) }) diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index efcd992137..62cdb33ef2 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -15,7 +15,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' // Borrowed read-only: this scenario needs any settled assistant message to // address, not a new recording (message-actions / sidebar-scrollbar pattern). -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'message-feedback-web-e2e' const NOTE = 'Read both files before answering.' diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index b4d48e23c8..22ab1bc476 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-system-prompt' import { @@ -21,11 +22,17 @@ import { import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/minimal-preset', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() const PROMPT = "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop." +/** Rendered text of the system prompt surface node, or undefined when the surface carries none. */ +function systemPromptText(session: Session): string | undefined { + const message = session.deriveMessages().find(candidate => candidate.role === 'system') + return message?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') +} + describe('minimal agent preset', () => { let scaffold: WebScaffold let agentHandle: AgentHandle @@ -69,15 +76,15 @@ describe('minimal agent preset', () => { if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed') }) - it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => { + it('sends the exact RL prompt and shell schema, then executes the persistent shell', async () => { const requestHeader = agentHandle.agent.session.requestHeader() if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') + const systemPrompt = systemPromptText(agentHandle.agent.session) + if (systemPrompt === undefined) throw new Error('the minimal agent issued no system prompt') expect(agentHandle.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false) - const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs') - expect(presetFileSystem).toBeDefined() - expect(presetFileSystem?.sandboxMode).toBeUndefined() + expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')).toBeUndefined() expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compaction')).toBeUndefined() const stateDir = join(scaffold.workspaceCwd, 'persistent-state') @@ -97,16 +104,6 @@ describe('minimal agent preset', () => { arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' }, agent: agentHandle.agent, }) - const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt') - await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n') - const editor = await scaffold.ctx.tools.execute({ - signal, - callId: ToolCallId('minimal-editor-smoke'), - name: 'str_replace_editor', - arguments: { command: 'view', path: seedPath }, - agent: agentHandle.agent, - }) - const text = (result: typeof bash): string => result.content .filter(block => block.type === 'text') .map(block => block.text) @@ -115,22 +112,18 @@ describe('minimal agent preset', () => { .trimEnd() expect({ - prompt: requestHeader.system, + prompt: systemPrompt, tools: requestHeader.tools?.map(tool => tool.name), goalCommand: scaffold.ctx.commands.find(agentHandle.agent, 'goal') !== undefined, bash: text(bash), - editor: text(editor), }).toMatchInlineSnapshot(` { - "bash": "PERSISTED:{{cwd}}/persistent-state", - "editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines): - 1 MINIMAL_EDITOR_OK - 2", + "bash": "PERSISTED:{{cwd}}/persistent-state + [Command finished with exit code 0]", "goalCommand": false, "prompt": "You are a helpful software engineer assistant.", "tools": [ "bash", - "str_replace_editor", ], } `) @@ -169,7 +162,7 @@ describe('minimal agent preset', () => { const call = row.locator('xpath=..') await call.getByText('IN', { exact: true }).waitFor() await call.getByText('OUT', { exact: true }).waitFor() - await call.getByText('MINIMAL_BASH_CARD_OK', { exact: true }).waitFor() + await call.getByText('MINIMAL_BASH_CARD_OK\n[Command finished with exit code 0]', { exact: true }).waitFor() await call.getByText(/"command": "printf 'MINIMAL_BASH_CARD_OK/).waitFor() const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) @@ -180,7 +173,7 @@ describe('minimal agent preset', () => { it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', + 'session.v3.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', diff --git a/apps/web/tests/models-settings-recovery.e2e.ts b/apps/web/tests/models-settings-recovery.e2e.ts new file mode 100644 index 0000000000..1087f8be3d --- /dev/null +++ b/apps/web/tests/models-settings-recovery.e2e.ts @@ -0,0 +1,94 @@ +/** Stored catalog drift remains repairable through the assembled Models settings page. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, + watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts' + +const EXPECTED = fileURLToPath(new URL('./expected/models-settings-recovery/stored-error.expected.md', import.meta.url)) +const FAILURE = 'llm-pi-ai: provider "openrouter" model "111" needs an api; ' + + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks' +const CUSTOM_FAILURE = 'llm-pi-ai: provider "acme-gateway" model "custom-model" needs an api; ' + + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks' + +describe('web e2e: repairs a stored provider after catalog drift', () => { + let home: string + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'dsh-models-recovery-')) + await writeFile(join(home, 'settings.yaml'), [ + 'llm-pi-ai:', ' providers:', ' openrouter:', ' models:', + ' - id: "111"', ' zai: {}', ' acme-gateway:', + ' baseURL: https://gateway.example/v1', ' models:', ' - id: "custom-model"', '', + ].join('\n')) + scaffold = await launchWebScaffold({ harnessHome: home }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await dialog.getByText(FAILURE, { exact: true }).waitFor() + }, 120_000) + + afterAll(async () => { + try { + await browser?.close() + } finally { + try { + await scaffold?.close() + } finally { + if (home !== undefined) await rm(home, { recursive: true, force: true }) + } + } + }) + + it('shows the failed provider beside healthy providers and keeps both add actions usable', async () => { + onTestFailed(() => saveFailureShot(page, 'models-settings-recovery')) + const dialog = page.getByRole('dialog', { name: '设置' }) + expect(await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '编辑 zai', exact: true }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '编辑 acme-gateway', exact: true }).count()).toBe(1) + expect(await dialog.getByText(CUSTOM_FAILURE, { exact: true }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '添加提供方', exact: true }).isEnabled()).toBe(true) + expect(await dialog.getByRole('button', { name: '添加自定义提供方', exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) + + await dialog.getByRole('button', { name: '添加提供方', exact: true }).click() + await dialog.getByLabel('提供方', { exact: true }).selectOption('minimax-cn') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor() + expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toContain('minimax-cn: {}') + expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(1) + }) + + it('rejects an invalid edit without persisting and accepts removal of the obsolete model', async () => { + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).click() + await dialog.getByText('自定义设置', { exact: true }).click() + await dialog.getByLabel('API 地址', { exact: true }).fill('https://gateway.example/v1') + const before = await readFile(join(home, 'settings.yaml'), 'utf8') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(() => dialog.getByText(FAILURE, { exact: true }).count()).toBe(2) + expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toBe(before) + await dialog.getByRole('button', { name: '删除模型 1', exact: true }).click() + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 openrouter。', { exact: true }).waitFor() + expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(0) + const repaired = await readFile(join(home, 'settings.yaml'), 'utf8') + expect(repaired).not.toContain('111') + expect(repaired).toContain('baseURL: https://gateway.example/v1') + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 0d9b32f1df..297257c678 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -22,7 +22,7 @@ import { import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url)) -const SEED = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') @@ -503,7 +503,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', 'search-results.expected.md', 'trajectory.expected.md', + 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', ]) }) diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts index e8b433c9e9..7323495984 100644 --- a/apps/web/tests/open-in-app-ssh.e2e.ts +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -13,7 +13,7 @@ import { import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/open-in-app-ssh', import.meta.url)) -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const SEED_ID = 'open-in-app-ssh-web-e2e' const MODE = webSnapshotMode() diff --git a/apps/web/tests/permission-policy-context.e2e.ts b/apps/web/tests/permission-policy-context.e2e.ts index 8cc9bebf3f..f0b9448470 100644 --- a/apps/web/tests/permission-policy-context.e2e.ts +++ b/apps/web/tests/permission-policy-context.e2e.ts @@ -18,7 +18,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context', import.meta.url)) -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() const PROMPTS = [ @@ -30,10 +30,10 @@ const PROMPTS = [ const PRESET_LABELS = ['Read Only', 'Full access', 'Workspace Write'] as const -function requestSystems(events: readonly SessionEvent[]): string[] { +function systemPrompts(events: readonly SessionEvent[]): string[] { return events.flatMap((event) => { - if (event.type !== 'request/header') return [] - return typeof event.data.header.system === 'string' ? [event.data.header.system] : [] + if (event.type !== 'system/message') return [] + return [event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')] }) } @@ -124,7 +124,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () => }, 240_000) it.skipIf(MODE === 'record')('records cache-safe current policy before the corresponding model behavior', async () => { - const systems = requestSystems(sessionEvents) + const systems = systemPrompts(sessionEvents) expect(systems).toHaveLength(1) expect(systems[0]).not.toContain('Current DSH file policy:') expect(systems[0]).not.toContain('Approval policy:') @@ -168,6 +168,6 @@ describe('web e2e: current sandbox policy reaches the model before tools', () => it.skipIf(MODE === 'record')('stays clean and keeps the fixture inventory closed', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'workspace.expected']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'workspace.expected']) }) }) diff --git a/apps/web/tests/plan-control-row.e2e.ts b/apps/web/tests/plan-control-row.e2e.ts index 7ccf54db9f..a9a1faec95 100644 --- a/apps/web/tests/plan-control-row.e2e.ts +++ b/apps/web/tests/plan-control-row.e2e.ts @@ -37,7 +37,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/plan-narrow-viewport', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md') const MODE = webSnapshotMode() @@ -143,6 +143,6 @@ describe('web e2e: plan chip click area at the narrow viewport', () => { }, 200_000) it('keeps the snapshot inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'layout.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'layout.expected.md']) }) }) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index b9ae32cec0..0bf7f5d5c9 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -22,7 +22,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/plan-review', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') // The waiting golden owns the decision card; the approved golden owns the // transcript the approval leaves behind — the state the card cannot see. const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md') @@ -129,7 +129,7 @@ describe('web e2e: plan review takeover round trip', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', 'review.expected.md', 'sidebar.expected.md', + 'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md', 'approved-expanded.expected.md', ]) }) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts new file mode 100644 index 0000000000..52aee329fa --- /dev/null +++ b/apps/web/tests/present.e2e.ts @@ -0,0 +1,186 @@ +/** Recorded source-file delivery, edits, reload, deletion, and Session ZIP behavior. */ +import { readFile, unlink, mkdir, mkdtemp, writeFile, rm, realpath } from 'node:fs/promises' +import { join, delimiter } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { unzipSync, strFromU8 } from 'fflate' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { tmpdir, release } from 'node:os' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-tool-present/types' +import { + acknowledgeReloadConnectionLoss, assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, + watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url)) +const FIXTURE = join(DIR, 'session.v3.jsonl') +const MODE = webSnapshotMode() +const PROMPT = 'Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. ' + + 'Use bash to run exactly `printf "DELIVERED_REPORT\\n" > report.txt; printf "DELIVERED_NOTE\\n" > 说明.txt`. ' + + 'Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string "AFTER_PRESENT" (not an Error object) from that same run_code program. ' + + 'Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph.' + +// The recorded Bash scenario and executable opener fixture require a POSIX host outside WSL. +describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes('microsoft'))('web e2e: explicit file delivery', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let sessionId: SessionId + let cwd: string + let disposeApproval: (() => void) | undefined + const events: SessionEvent[] = [] + let nativeRoot: string | undefined + let openLog: string + const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string }) + const downloads: string[] = [] + + beforeAll(async () => { + nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-present-native-')) + openLog = join(nativeRoot, 'opened.jsonl') + await writeFile(openLog, '') + // Exercise the built Host through its actual OS command, replacing only the desktop application. + const command = process.platform === 'darwin' ? 'open' : 'xdg-open' + await writeFile(join(nativeRoot, command), `#!${process.execPath} +const fs = require('node:fs'); +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n'); +`, { mode: 0o700 }) + vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) + await mkdir(DIR, { recursive: true }) + scaffold = await launchWebScaffold({ + agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), + }) + disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true }) + scaffold.ctx.on('session/event', (_session, event) => { events.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + page.on('download', (download) => { downloads.push(download.suggestedFilename()) }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + try { + await browser?.close() + } finally { + disposeApproval?.() + try { + await scaffold?.close() + } finally { + vi.unstubAllEnvs() + if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true }) + } + } + }) + + it('declares nested deliveries even when the enclosing program subsequently fails', async () => { + if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill(PROMPT) + await input.press('Enter') + sessionId = await settled + const workspace = scaffold.ctx.agents.get(sessionId)?.session.header.cwd + if (workspace === undefined) throw new Error('present Session has no workspace') + cwd = workspace + if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) + await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 }) + await assertFinalWorkspaceSnapshot(DIR, cwd) + expect(events.filter(event => event.type === 'deliverables/presented').flatMap(event => event.data.files.map(file => file.path))) + .toEqual(['report.txt', '说明.txt']) + for (const event of events) { + if (event.type === 'deliverables/presented') { + expect(event.data.files).toEqual([ + { path: 'report.txt', description: 'delivered report' }, + { path: '说明.txt', description: 'delivered note' }, + ]) + } + } + expect(events.some(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'present' && event.data.isError)).toBe(true) + expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError)).toBe(true) + }, 200_000) + + it('opens current source files after edits and reload, and reports deletion without downloading', async () => { + await writeFile(join(cwd, 'report.txt'), 'EDITED_REPORT\n') + await writeFile(join(cwd, '说明.txt'), 'EDITED_NOTE\n') + for (const reload of [false, true]) { + if (reload) { + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 }) + } + const row = page.locator('[data-presented-files-row]') + await row.waitFor() + expect(await row.getByRole('button').count()).toBe(2) + for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { + const count = (await opened()).length + const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await row.getByRole('button', { name: `Open ${name} in default app`, exact: true }).click() + expect((await response).status()).toBe(204) + await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect(await opened()).toHaveLength(count + 1) + expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes }) + } + } + const count = (await opened()).length + const openedResponse = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await page.locator('code').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click() + await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect((await openedResponse).status()).toBe(204) + expect(await opened()).toHaveLength(count + 1) + expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) + expect(downloads).toEqual([]) + const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) + expect(response.status()).toBe(200) + const entries = unzipSync(await response.body()) + expect(Object.keys(entries)).toHaveLength(1) + const exported = strFromU8(Object.values(entries)[0]!) + expect(exported).toContain('deliverables/presented') + const declarations = exported.trim().split('\n').map(line => JSON.parse(line) as SessionEvent) + .filter(event => event.type === 'deliverables/presented') + expect(declarations).toHaveLength(1) + expect(declarations[0]!.data.files).toEqual([ + { path: 'report.txt', description: 'delivered report' }, + { path: '说明.txt', description: 'delivered note' }, + ]) + expect(exported).not.toContain('EDITED_REPORT') + if (MODE !== 'record') { + const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) + await page.locator('[data-turn-process]').click() + const failed = page.locator('[data-tool="present"][data-state="error"]') + const delivered = page.locator('[data-tool="present"][data-state="ok"]') + expect(await failed.count()).toBe(1) + expect(await delivered.count()).toBe(1) + expect(await failed.innerText()).toContain('Delivery failed') + expect(await delivered.innerText()).toContain('Delivered') + await page.locator('[data-turn-process]').click() + await page.setViewportSize({ width: 480, height: 900 }) + const row = page.locator('[data-presented-files-row]') + await row.scrollIntoViewIfNeeded() + for (const card of await row.getByRole('button').all()) { + const bounds = await card.boundingBox() + expect(bounds).not.toBeNull() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(480) + } + } + const beforeDelete = (await opened()).length + await unlink(join(cwd, 'report.txt')) + const missing = page.waitForResponse(response => response.url().includes('/api/present.open?')) + await page.locator('[data-presented-files-row]').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click() + expect((await missing).status()).toBe(404) + await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor() + expect(await opened()).toHaveLength(beforeDelete) + expect(downloads).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts new file mode 100644 index 0000000000..7fb3d881d2 --- /dev/null +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -0,0 +1,73 @@ +/** Cold V2 restoration mounts the shipped PTC preset and publishes only a V3 successor. */ + +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { zstdCompressSync, zstdDecompressSync } from 'node:zlib' +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { generationLogPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' +import { scanZstdFrames } from '../../../packages/session/session-persistence-jsonl/src/zstd.ts' +import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-api-session-controller' +import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot' +import { launchWebScaffold, webSnapshotMode } from './scaffold.ts' + +const fixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.v2.jsonl', import.meta.url)) + +describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration through the Web Host', () => { + it.each([false, true])('resumes code as PTC (selection events=%s)', async (withSelections) => { + const scaffold = await launchWebScaffold() + try { + const id = SessionId('preset-migration') + const fixture = await readFile(fixturePath, 'utf8') + const [fixtureHeader, ...fixtureEvents] = fixture.trimEnd().split('\n') + .map((line): Record => JSON.parse(line) as Record) + const events = withSelections ? fixtureEvents : fixtureEvents.filter(event => event['type'] !== 'agent-preset/selected') + const header = { ...fixtureHeader, id, cwd: scaffold.workspaceCwd } + const rows: Record[] = events.map((event, seq) => ({ ...event, seq, time: seq + 2 })) + const source = Buffer.concat([header, ...rows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n')))) + const predecessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, id, 2, 'zstd') + const successor = join(dirname(predecessor), 'session.v3.jsonl.zstd') + await mkdir(dirname(predecessor), { recursive: true }) + await writeFile(predecessor, source) + + const reader = await scaffold.ctx.sessionPersistence.open(id, 'read') + try { + expect(reader.header.agentPreset).toBe('ptc') + await reader.read() + } finally { + await reader.close() + } + await expect(readFile(successor)).rejects.toMatchObject({ code: 'ENOENT' }) + + const resolved = await scaffold.ctx.sessionController.resolveAgent(id) + if ('error' in resolved) throw resolved.error + expect(scaffold.ctx.agentPresets.composedPreset(resolved.agent.ctx)).toBe('ptc') + expect(resolved.agent.session.header.agentPreset).toBe('ptc') + expect(resolved.agent.session.snapshotEvents() + .filter(event => event.type === 'agent-preset/selected') + .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) + + const publishedBytes = await readFile(successor) + const published = Buffer.concat(scanZstdFrames(publishedBytes).frames + .map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8') + const expected = [ + { ...header, version: 3, agentPreset: 'ptc' }, + ...rows.map(row => row['type'] === 'agent-preset/selected' + && (row['data'] as { agentPreset: string }).agentPreset === 'code' + ? { ...row, data: { agentPreset: 'ptc' } } + : row), + // Agent activation closes its restored prefix with a fresh seed marker. + { type: 'session/end-seed', seq: rows.length, time: 0, data: {} }, + ].map(row => JSON.stringify(row)).join('\n') + '\n' + const context = { sessionIds: [id], cwd: scaffold.workspaceCwd } + expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context)) + expect(await readFile(predecessor)).toEqual(source) + expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl.zstd')).sort()) + .toEqual(['session.v2.jsonl.zstd', 'session.v3.jsonl.zstd']) + } finally { + await scaffold.close() + } + }) +}) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 6f8d2aab97..90dc202cf0 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -415,7 +415,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { await page.getByText(SHOWCASE_OLDEST, { exact: true }).waitFor({ timeout: 15_000 }) expect(pageErrors.map(error => error.message)).toEqual([]) expect(consoleErrors.filter(line => - /watchFile|failed to watch|node-addon-landlock-run\.probe|sandbox backend is usable|SANDBOX_UNAVAILABLE/i.test(line))).toEqual([]) + /watchFile|failed to watch|node-addon-system\.probe|sandbox backend is usable|SANDBOX_UNAVAILABLE/i.test(line))).toEqual([]) } catch (error) { await saveFailureShot(page, 'preview-boot') throw pageErrors.length === 0 diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index f81ba01ef6..045c475bb6 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -1,5 +1,5 @@ // PTC mode browser round trip with nested sub-calls and details selection. -// Record: DSH_SNAPSHOT=record writes session.v2.jsonl, then a keyless +// Record: DSH_SNAPSHOT=record writes session.v3.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -13,7 +13,7 @@ import { } from './scaffold.ts' import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -68,17 +68,22 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { const calls = sessionEvents.filter(event => event.type === 'tool/call') expect(calls.length).toBeGreaterThanOrEqual(1) expect(new Set(calls.map(call => (call.data as { name: string }).name))).toEqual(new Set(['run_code'])) - const dispatches = sessionEvents.filter(event => (event.type as string) === 'tool/code-dispatch') + const starts = sessionEvents.filter(event => event.type === 'tool/ptc-dispatch-start') + const dispatches = sessionEvents.filter(event => event.type === 'tool/ptc-dispatch') expect(dispatches.length).toBeGreaterThanOrEqual(2) for (const dispatch of dispatches) { - const data = dispatch.data as unknown as { - parentCallId: string - subCallId: string - name: string - isError: boolean - content: { type: string }[] - } - expect(data.subCallId.startsWith(`${data.parentCallId}:code:`)).toBe(true) + const data = dispatch.data + expect(calls.some(call => call.data.callId === data.rootCallId)).toBe(true) + expect(data.parentCallId).toBe(data.rootCallId) + expect(starts.filter(start => start.data.subCallId === data.subCallId)).toMatchObject([{ + data: { + rootCallId: data.rootCallId, + parentCallId: data.parentCallId, + subCallId: data.subCallId, + name: data.name, + arguments: data.arguments, + }, + }]) expect(Array.isArray(data.content)).toBe(true) expect(typeof data.isError).toBe('boolean') } diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts index ec86ddc4b1..6069367555 100644 --- a/apps/web/tests/pwsh-terminal.e2e.ts +++ b/apps/web/tests/pwsh-terminal.e2e.ts @@ -25,7 +25,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/pwsh-terminal', import.meta.url)) -const SEED = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const OVERLAY = fileURLToPath(new URL('./pwsh-terminal.overlay.yml', import.meta.url)) const PROMPT = 'Run a PowerShell command that fails, then stop.' @@ -101,6 +101,6 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas }, 60_000) it('guards the lane fixture inventory', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'terminal-card.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'terminal-card.expected.md']) }) }) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index d12abc2712..45ba98f7ac 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -24,7 +24,7 @@ import { } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') @@ -410,7 +410,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () it('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', + 'session.v3.jsonl', 'ui.expected.md', 'sidebar.expected.md', 'composed.expected.md', diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 65f646187a..7c4ce8d4bf 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -19,7 +19,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queue-actions', import.meta.url)) -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)) const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md') const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md') const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md') @@ -65,6 +65,16 @@ describe('web e2e: queue row actions', () => { if (failures.length > 1) throw new AggregateError(failures, 'queue-actions teardown failed') }) + /** Wait for the exact queue mutation response before observing its unlocked actions. */ + async function settleQueueAction(action: () => Promise, remainingText: string): Promise { + const response = page.waitForResponse('**/api/session/updateQueue') + await action() + expect((await response).ok()).toBe(true) + const row = page.locator('[data-queue-dock] li', { hasText: remainingText }) + await expect.poll(() => row.getByRole('button', { name: 'Edit queued message' }).isEnabled()).toBe(true) + await expect.poll(() => row.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true) + } + it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => { overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-')) const readyFile = join(overrideDir, '.hang-ready') @@ -96,6 +106,7 @@ describe('web e2e: queue row actions', () => { await input.press('Enter') await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true) + const admitted = page.waitForResponse('**/api/session/prompt') const received = Promise.withResolvers() const release = Promise.withResolvers() await page.route('**/api/session/prompt', async (route) => { @@ -125,6 +136,7 @@ describe('web e2e: queue row actions', () => { } finally { release.resolve(undefined) } + expect((await admitted).ok()).toBe(true) await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true) expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0) expect(await page.locator('[data-queue-dock]').getByRole('status').count()).toBe(0) @@ -179,11 +191,11 @@ describe('web e2e: queue row actions', () => { await page.getByRole('tooltip', { name: 'Save queued message', exact: true }).waitFor() const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE) - await page.getByRole('button', { name: 'Save queued message' }).click() + await settleQueueAction(() => page.getByRole('button', { name: 'Save queued message' }).click(), EDITED) await page.getByText(EDITED, { exact: true }).waitFor() const removeRow = page.locator('[data-queue-dock] li', { hasText: REMOVE }) - await removeRow.getByRole('button', { name: 'Remove queued message' }).click() + await settleQueueAction(() => removeRow.getByRole('button', { name: 'Remove queued message' }).click(), EDITED) await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0) // The queue stream can remove the row before the mutation reply clears busy. const remainingEdit = page.getByRole('button', { name: 'Edit queued message', exact: true }) diff --git a/apps/web/tests/queue-image.e2e.ts b/apps/web/tests/queue-image.e2e.ts index 9e641bc77c..c78d26f013 100644 --- a/apps/web/tests/queue-image.e2e.ts +++ b/apps/web/tests/queue-image.e2e.ts @@ -21,7 +21,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queued-image', import.meta.url)) -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)) const PNG = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url)) const QUEUED_EXPECTED = join(SNAPSHOT_DIR, 'queued.expected.md') const DELIVERED_EXPECTED = join(SNAPSHOT_DIR, 'delivered.expected.md') @@ -100,12 +100,13 @@ describe('web e2e: queued image submission', () => { await input.fill(QUEUED_TEXT) await input.press('Enter') - // The queued row renders the durable thumbnail beside the text preview. - const dockThumb = page.locator('[data-queue-dock] img[alt="Queued message image"]') + // Admission replaces the local preview; the durable row loads its own thumbnail. + await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 }) + const dockThumb = page.locator('[data-queue-dock] li:not([data-submission-echo]) img[alt="Queued message image"]') await dockThumb.waitFor({ timeout: 15_000 }) await expect.poll(() => dockThumb.getAttribute('src')).toMatch(/^blob:/) + await expect.poll(() => dockThumb.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true) await page.getByText(QUEUED_TEXT, { exact: true }).waitFor() - await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 }) const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE) @@ -136,6 +137,14 @@ describe('web e2e: queued image submission', () => { ).toBe(0) const chatImage = page.locator('[class*="userRow"] img') await chatImage.first().waitFor({ timeout: 15_000 }) + // Host persistence precedes delivery to the browser; require the waking turn's settled tail. + await page.locator('[data-turn-tail="3"]') + .getByRole('button', { name: 'Branch into a new conversation', exact: true }) + .waitFor({ timeout: 15_000 }) + await expect.poll( + () => page.getByRole('button', { name: /^3 turns 3 steps/ }).count(), + { timeout: 15_000 }, + ).toBe(1) const deliveredSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(DELIVERED_EXPECTED, deliveredSnapshot, MODE) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index bd3993db51..db60b35753 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -6,7 +6,7 @@ // record cannot hang on a live model answering differently); assertion steps // run in replay/refresh only. Settled states only — streaming fidelity is // asserted from the durable embedded Assistant stream, not transient DOM. -// Record: DSH_SNAPSHOT=record writes session.v2.jsonl, then a keyless +// Record: DSH_SNAPSHOT=record writes session.v3.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -15,7 +15,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, @@ -26,7 +26,7 @@ import { } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url)) -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url)) const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url)) const UI_EXPANDED_EXPECTED = fileURLToPath( @@ -40,6 +40,12 @@ const MODE = webSnapshotMode() // drift apart. const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.' +/** Rendered text of the system prompt surface node, or undefined when the surface carries none. */ +function systemPromptText(session: Session): string | undefined { + const message = session.deriveMessages().find(candidate => candidate.role === 'system') + return message?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') +} + describe('web e2e: fresh round trip through the real assembly', () => { let scaffold: WebScaffold let browser: Browser @@ -101,11 +107,11 @@ describe('web e2e: fresh round trip through the real assembly', () => { } }, 200_000) - it('ends the request header with the source checkout, Web surface, and session cwd', async () => { + it('ends the system prompt with the source checkout, Web surface, and session cwd', async () => { if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') const agent = scaffold.ctx.agents.get(settledSessionId) if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) - const system = agent.session.requestHeader()?.system + const system = systemPromptText(agent.session) if (system === undefined) throw new Error('the settled Web request has no system prompt') const paragraphs = system.split('\n\n') expect(paragraphs.slice(0, 2)).toEqual([ @@ -223,7 +229,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', + 'session.v3.jsonl', 'submission-echo.expected.md', 'system-prompt.expected.md', 'tool-schemas.expected.json', diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f51379705e..4bc734aa4b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -47,7 +47,7 @@ import { redactSessionSnapshotIds, normalizeSessionSnapshots, parseSessionFixtureName, - scrubRequestHeaders, + scrubModelRequestBulk, scrubSessionSnapshot, sessionFixtureFiles, sessionFixtureName, @@ -1080,9 +1080,10 @@ async function assertReplaySession( } /** - * Record-mode fixture write-back: harvest the live session, scrub request - * headers to {{system}}/{{tools}}, tokenize the run-local cwd and Harness Home, redact opaque - * identities with typed relationship-preserving tokens, and write the fixture. + * Record-mode fixture write-back: harvest the live session, scrub the + * system-prompt text to {{system}} and header tool schemas to {{tools}}, + * tokenize the run-local cwd, redact opaque identities with typed + * relationship-preserving tokens, and write the fixture. * A manifest-retained historical generation makes the write-back a no-op. * @param scaffold - the record-mode scaffold. * @param sessionId - the driven session. @@ -1555,7 +1556,7 @@ export async function assertFixtureInventory(dir: string, expected: string[]): P } for (const entry of artifacts.filter(name => name.endsWith('.jsonl'))) { const content = await readFile(join(dir, entry), 'utf8') - expect(scrubRequestHeaders(content), `${dir}/${entry} carries request-header bulk`).toBe(content) + expect(scrubModelRequestBulk(content), `${dir}/${entry} carries prompt text or tool-schema bulk`).toBe(content) expect(redactSessionSnapshotIds([content]), `${dir}/${entry} carries unredacted identities`).toEqual([content]) } } diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index adf5ca1b1e..587a355f4f 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -58,7 +58,7 @@ const EVERY_REPLY = 'Reminders: Check primary metrics; Check secondary metrics.' const EVERY_INTERVAL_SECONDS = 60 * 60 const EVERY_FIXTURE_AGE_MS = 90 * 60 * 1_000 const CATALOG_SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/schedule-catalog', import.meta.url)) -const CATALOG_FIXTURE = join(CATALOG_SNAPSHOT_DIR, 'session.v2.jsonl') +const CATALOG_FIXTURE = join(CATALOG_SNAPSHOT_DIR, 'session.v3.jsonl') const CATALOG_EXPECTED = join(CATALOG_SNAPSHOT_DIR, 'catalog.expected.md') const BASE_PATCH = fileURLToPath(new URL('../../../packages/bundle/base/cordis.patch.yml', import.meta.url)) const WEB_PATCH = fileURLToPath(new URL('../../../packages/bundle/web-app/cordis.patch.yml', import.meta.url)) @@ -816,7 +816,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { }).toBe(0) await assertFixtureInventory(CATALOG_SNAPSHOT_DIR, [ 'catalog.expected.md', - 'session.v2.jsonl', + 'session.v3.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', ]) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index a6ab6adf15..09110c5360 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -9,7 +9,7 @@ // `/feedback` pins its expandable correlation ids. The seed is a recorded // fixture under the same record discipline as every other: DSH_SNAPSHOT=record drives the turn // live through the composer (real read tool against seeded workspace files) -// and harvests session.v2.jsonl; replay/refresh seed it cold and only render. +// and harvests session.v3.jsonl; replay/refresh seed it cold and only render. import { readFile, writeFile, mkdir } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -30,7 +30,7 @@ import { import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url)) -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url)) const UI_EXPANDED_EXPECTED = fileURLToPath( new URL('../../../snapshots/web/seeded-history/ui-expanded.expected.md', import.meta.url), @@ -157,7 +157,7 @@ function withCompaction(raw: string, meter: TokenMeter): string { kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId, }, }), - surfaceOp: { op: 'replace', start: first, end: last }, + surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], }) at({ @@ -433,6 +433,8 @@ describe('web e2e: seeded history renders through cold resume', () => { // Put the column back so the later goldens see the default frame. await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') + await page.getByRole('button', { name: 'Open the sidebar', exact: true }).waitFor({ state: 'visible' }) + await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' }) }) it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { @@ -461,7 +463,8 @@ describe('web e2e: seeded history renders through cold resume', () => { // the command's own name). await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click() await page.getByRole('menuitem', { name: 'Read Only' }).click() - await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 }) + const access = page.getByRole('button', { name: 'Access mode, current: Read Only' }) + await expect.poll(() => access.isEnabled(), { timeout: 10_000 }).toBe(true) // Scoped to the row itself, so unrelated page text that happens to read // `permission` (a future resident slash menu) cannot satisfy or break it. const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' }) @@ -543,7 +546,7 @@ describe('web e2e: seeded history renders through cold resume', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md', - 'session.v2.jsonl', 'ui.expected.md', 'ui-expanded.expected.md', + 'session.v3.jsonl', 'ui.expected.md', 'ui-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index f1db2bb51f..55d64f199f 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -10,9 +10,9 @@ // open llm seam. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import { join } from 'node:path' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -190,13 +190,33 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + async function selectTheme(cube: Locator, preference: 'light' | 'dark' | 'system'): Promise { + // Optimistic UI and a file value from an earlier gesture do not prove this write finished. + const [response] = await Promise.all([ + page.waitForResponse((candidate) => { + if (candidate.request().method() !== 'POST' + || new URL(candidate.url()).pathname !== '/api/settings/mutate') return false + const { payload: { args } } = candidate.request().postDataJSON() as { + payload: { args: { ns: string; ops: { op: string; path: string[]; value?: unknown }[] } } + } + return args.ns === 'ui-theme' && args.ops.some(op => op.op === 'set' + && op.path.length === 1 && op.path[0] === 'preference' && op.value === preference) + }, { timeout: 5_000 }), + cube.click(), + ]) + expect(response.ok()).toBe(true) + expect(await response.json()).toMatchObject({ + result: { ok: true, value: { ns: 'ui-theme', value: { preference } } }, + }) + } + it('uses the persisted dark preference while plugins are still loading', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-boot-theme')) await page.emulateMedia({ colorScheme: 'light' }) await page.getByRole('button', { name: '设置', exact: true }).click() const initialDialog = page.getByRole('dialog', { name: '设置' }) const darkCube = initialDialog.getByRole('button', { name: '深色' }) - await darkCube.click() + await selectTheme(darkCube, 'dark') await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) .toMatch(/ui-theme:\n\s+preference: dark/) @@ -242,7 +262,7 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const restoredDialog = page.getByRole('dialog', { name: '设置' }) const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' }) - await systemCube.click() + await selectTheme(systemCube, 'system') await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), { timeout: 5_000, @@ -291,7 +311,7 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.waitFor({ timeout: 10_000 }) const darkCube = dialog.getByRole('button', { name: '深色' }) expect(await darkCube.getAttribute('aria-pressed')).toBe('false') - await darkCube.click() + await selectTheme(darkCube, 'dark') // The full cascade: pressed state, Host-backed preference, body attribute, // alias token flip — all from one real user gesture. await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') @@ -340,7 +360,7 @@ describe('web e2e: settings modal and General preferences', () => { // `system` follows the emulated OS scheme (dark stays dark, light clears). await page.getByRole('button', { name: '设置', exact: true }).click() const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' }) - await systemCube.click() + await selectTheme(systemCube, 'system') await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) expectThemeColorSynchronized(await readState()) @@ -349,7 +369,7 @@ describe('web e2e: settings modal and General preferences', () => { expectThemeColorSynchronized(await readState()) // Restore for the specs that follow: light preference beats the emulated // dark OS scheme, leaving the shared page in the light default. - await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click() + await selectTheme(page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }), 'light') await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) expectThemeColorSynchronized(await readState()) await page.keyboard.press('Escape') @@ -358,6 +378,10 @@ describe('web e2e: settings modal and General preferences', () => { it('steps the content font size, applies it to body, and persists across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-font-size')) + onTestFinished(async () => { + await page.keyboard.press('Escape') + await page.getByRole('dialog', { name: '设置', exact: true }).waitFor({ state: 'hidden' }) + }) const readFontSize = async (target: Page = page): Promise => await target.evaluate( () => document.body.style.getPropertyValue('--dsh-content-font-size'), ) @@ -372,6 +396,24 @@ describe('web e2e: settings modal and General preferences', () => { probe.remove() return size }) + // The displayed value is optimistic; wait for the write before the next step. + const stepFontSize = async (button: Locator, px: number): Promise => { + const [response] = await Promise.all([ + page.waitForResponse((reply) => { + if (new URL(reply.url()).pathname !== '/api/settings/mutate' || reply.request().method() !== 'POST') return false + const request = reply.request().postDataJSON() as { payload: { args: { ns: string } } } + return request.payload.args.ns === 'ui-theme' + }), + button.click(), + ]) + expect(await response.finished()).toBeNull() + const envelope = await response.json() as { result: { ok: boolean } } + expect(envelope.result.ok).toBe(true) + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(new RegExp(`ui-theme:\n(?:\\s+\\w+: .*\n)*?\\s+fontSize: ${px}`)) + await page.getByRole('dialog', { name: '设置' }).getByText(String(px), { exact: true }).waitFor({ timeout: 5_000 }) + await expect.poll(readFontSize, { timeout: 5_000 }).toBe(`${px}px`) + } expect(await readFontSize()).toBe('14px') expect(await readSecondaryFontSize()).toBe('13px') await page.getByRole('button', { name: '设置', exact: true }).click() @@ -380,17 +422,12 @@ describe('web e2e: settings modal and General preferences', () => { // The stepper reveals its arrows on hover; the up arrow steps 14 → 15 → 16. await dialog.getByText('14', { exact: true }).hover() const increase = dialog.getByRole('button', { name: '增大字号' }) - await increase.click() - await dialog.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) + await stepFontSize(increase, 15) // 15 is the piecewise boundary: the secondary tier holds at 13px (−2) // where the ≤14 branch would have given 14px (−1). await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('13px') - await increase.click() - await dialog.getByText('16', { exact: true }).waitFor({ timeout: 5_000 }) - await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px') + await stepFontSize(increase, 16) await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('14px') - await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) - .toMatch(/ui-theme:\n(?:\s+\w+: .*\n)*?\s+fontSize: 16/) await page.keyboard.press('Escape') // Reload: the boot script embeds the durable size and ThemeRuntime seeds @@ -409,11 +446,8 @@ describe('web e2e: settings modal and General preferences', () => { await restored.waitFor({ timeout: 10_000 }) await restored.getByText('16', { exact: true }).hover() const decrease = restored.getByRole('button', { name: '减小字号' }) - await decrease.click() - await restored.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) - await decrease.click() - await restored.getByText('14', { exact: true }).waitFor({ timeout: 5_000 }) - await expect.poll(readFontSize, { timeout: 5_000 }).toBe('14px') + await stepFontSize(decrease, 15) + await stepFontSize(decrease, 14) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 61acc027fd..fc0ef1694c 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -45,6 +45,7 @@ const EXPECTED_TOOLS = [ 'job_list', 'job_output', 'list_agents', + 'present', 'ralph', 'read', 'read_image', diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 69196119d3..257e0c9f6e 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -132,7 +132,7 @@ async function resetSidebar(page: Page): Promise { const column = page.locator('[data-rightbar-col]') await expandOf(page).waitFor({ timeout: 15_000 }) await ensureExpanded(page, column) - await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) await width(column) return column } @@ -370,7 +370,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(Math.round(gapAfter)).toBe(Math.round(gapBefore)) // The panel is in the column, not over it, and carries the seeded tab — - // whose body arrives through the guide type's keyed registration, not from + // whose body arrives through the Files type's keyed registration, not from // any dispatch inside the seat. Its two controls sit at the end of the // top-right pane's strip: the panel has no header row of its own. expect(await column.locator('[data-sidebar-right-panel="push"]').count()).toBe(1) @@ -380,8 +380,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(await chrome.locator('[data-sidebar-right-toggle]').count()).toBe(1) // One centre line across the strip: chip text, split, and the two panel - // controls all sit at the same height. The add control joins the check - // below, once the strip draws it. + // controls all sit at the same height. The add control joins the check below. const centreY = async (selector: string): Promise => { const box = await column.locator(selector).first().boundingBox() if (box === null) throw new Error(`${selector} is not rendered`) @@ -392,24 +391,26 @@ describe('web e2e: shipped right Sidebar', () => { expect(await centreY(selector), selector).toBe(textLine) } - // The guide is unique per pane, so while this pane holds one its strip - // offers no add control. Closing it brings the control back, and the - // control opens the guide again in that pane. + // Files is permanent. A manual guide is closable and suppresses another + // add control in its pane until it is closed. const addTab = column.locator('[data-dockkit-add-tab]') - expect(await addTab.count()).toBe(0) - await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) - await column.locator('[data-dockkit-tab-close]').first().click() - await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME]) + const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) + await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) + expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0) await expect.poll(async () => await addTab.count()).toBe(1) expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine) await addTab.click() - await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start']) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start']) await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1) await expect.poll(async () => await addTab.count()).toBe(0) + expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0) + const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1) // Back to the seeded shape the cases below start from. - await column.locator('[data-dockkit-tab-close]').first().click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await guideTab.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) + await expect.poll(async () => await addTab.count()).toBe(1) await shot(page, '02-squeezed-panel') expect(tripwire.pageErrors).toEqual([]) @@ -648,12 +649,12 @@ describe('web e2e: shipped right Sidebar', () => { // text type claims the address. const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }) await chip.click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) // Opening the same content again focuses rather than duplicating. await panes.first().locator('[data-dockkit-tab]').first().click() await chip.click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) // The body arrives through the text type's keyed registration, and its // content came over the wire from the real file. @@ -684,15 +685,15 @@ describe('web e2e: shipped right Sidebar', () => { ) await expect.poll(async () => await panes.count()).toBe(2) - await panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) - .locator('[data-dockkit-tab-close]').click() + const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0) + await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) - // The guide is unique per pane: panes seeded with one offer no - // add control; the pane holding only the file is the one that does. + // Neither pane holds a guide, so both offer an add control. const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) }) await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1) - expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(1) + expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(2) // Floating leaves the column entirely, and survives collapsing it. The // pane the tab was alone in goes with it: an emptied pane never stays. @@ -780,7 +781,7 @@ describe('web e2e: shipped right Sidebar', () => { const first = panes.first() const strip = first.locator('[data-dockkit-strip]') await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() - await expect.poll(async () => await tabTitles(first)).toEqual(['Start', SAMPLE_NAME]) + await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME]) const order = await tabTitles(first) // The insertion index is measured against chip midpoints, not strip width. await dragTo(page, first.locator('[data-dockkit-tab]').last(), @@ -824,10 +825,8 @@ describe('web e2e: shipped right Sidebar', () => { expect(await panes.count()).toBe(2) expect(await splitButtons.count()).toBe(0) - // 5. Two floats coexist, and one of them moves. Both leave the widest - // pane; a pane emptied by the first float is merged away, so the - // second one comes from whichever pane is widest by then. - const floatOne = panes.last().locator('[data-dockkit-tab]').first() + // 5. A manual guide and the document float while Files stays docked. + const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) await floatByDrag(page, floatOne) await expect.poll(async () => await floats.count()).toBe(1) const box = await floats.first().boundingBox() @@ -835,7 +834,9 @@ describe('web e2e: shipped right Sidebar', () => { await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x) - const second = panes.last().locator('[data-dockkit-tab]').first() + await panes.last().locator('[data-dockkit-add-tab]').click() + await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Files', 'Start']) + const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await floatByDrag(page, second) await expect.poll(async () => await floats.count()).toBe(2) @@ -849,34 +850,33 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) - it('drops a pane whose last tab closes, and reseeds the guide when none is left', async () => { + it('keeps the last tab unclosable and drops a pane emptied by a drag', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle')) const column = await resetSidebar(page) const panes = column.locator('[data-dockkit-pane]') + expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) + await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME]) await panes.first().locator('[data-dockkit-split-button]').click() await expect.poll(async () => await panes.count()).toBe(2) - const closeAllIn = async (pane: Locator): Promise => { - const tabs = await pane.locator('[data-dockkit-tab]').count() - for (let i = 0; i < tabs; i += 1) { - await pane.locator('[data-dockkit-tab-close]').first().click() - } - } + await dragTo(page, panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }), + await pointIn(panes.nth(1), 0.5, 0.5)) + await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual(['Files', SAMPLE_NAME]) + const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0) + await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) + await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) - // Closing a pane's last tab drops the pane: there is no separate - // "close pane" gesture, and none is needed. - let count = await panes.count() - expect(count).toBeGreaterThan(1) - while (count > 1) { - await closeAllIn(panes.nth(count - 1)) - await expect.poll(async () => await panes.count()).toBe(count - 1) - count -= 1 - } - - // The last pane cannot be dropped, so closing everything in it reseeds - // the guide: the surface always has one tab to look at. - await closeAllIn(panes.first()) - await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) - expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1) + // The remaining ordinary document is also unclosable while alone. + expect(await panes.nth(1).locator('[data-dockkit-tab-close]').count()).toBe(0) + await dragTo(page, panes.nth(1).locator('[data-dockkit-tab]'), await pointIn(panes.first(), 0.5, 0.5)) + await expect.poll(async () => await panes.count()).toBe(1) + const documentTab = panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) + await expect.poll(async () => await documentTab.locator('[data-dockkit-tab-close]').count()).toBe(1) + await documentTab.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await documentTab.count()).toBe(0) + expect(await panes.count()).toBe(1) + expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -902,10 +902,12 @@ describe('web e2e: shipped right Sidebar', () => { await ensureExpanded(page, column) await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0) + await column.locator('[data-dockkit-add-tab]').click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start']) // No "more" control on the chip: the chip carries its close, and the menu // is the secondary press. expect(await column.locator('[data-dockkit-tab-more]').count()).toBe(0) - await column.locator('[data-dockkit-tab]').first().click({ button: 'right' }) + await column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }).click({ button: 'right' }) const menu = page.locator('[data-dockkit-tab-menu]') await expect.poll(async () => await menu.count()).toBe(1) @@ -947,6 +949,8 @@ describe('web e2e: shipped right Sidebar', () => { const column = zhPage.locator('[data-rightbar-col]') await expandOf(zhPage).waitFor({ timeout: 20_000 }) await expandOf(zhPage).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['文件']) + await column.locator('[data-dockkit-add-tab]').click() const guide = column.locator('[data-sidebar-right-guide]') await expect.poll(async () => await guide.count()).toBe(1) @@ -954,7 +958,7 @@ describe('web e2e: shipped right Sidebar', () => { // the column has the width, and a screenshot taken mid-transition reads // as a layout defect that is not there. expect(await width(column)).toBeGreaterThan(300) - await expect.poll(async () => await tabTitles(column)).toEqual(['开始']) + await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始']) await expect.poll(async () => await guide.locator('p').first().innerText()) .toBe('侧栏用来放你想一直看着的东西。') await shot(zhPage, '05-guide-copy-zh') diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index d1c14f0379..0f1443c8d9 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -15,7 +15,7 @@ import { } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/sidebar-scrollbar', import.meta.url)) /** Geometry and resolved style are absent from ARIA snapshots, so this scenario records them directly. */ const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') @@ -412,7 +412,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum }, 60_000) it('commits exactly the fixtures it reads', async () => { - // The scenario borrows seeded-history's session.v2.jsonl rather than committing a + // The scenario borrows seeded-history's session.v3.jsonl rather than committing a // second copy, so this directory holds the golden alone. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) }) diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index d32e50ea86..ce84e0b76e 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -12,7 +12,7 @@ import { } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v3.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() diff --git a/apps/web/tests/stats-paged-history.e2e.ts b/apps/web/tests/stats-paged-history.e2e.ts index 45e5d3de2d..6fc50a5e2b 100644 --- a/apps/web/tests/stats-paged-history.e2e.ts +++ b/apps/web/tests/stats-paged-history.e2e.ts @@ -1,5 +1,5 @@ // Web e2e scenario: full-session stats over paged history. A deterministic -// 28-turn log (56 surface messages — more than one 50-message history page) +// 28-turn log (56 chat messages — more than one 50-message history page) // seeded cold through the REAL persistence API must render whole-log turn/step // counts from the sessionStats projection on first open, and loading the // older page must NOT change them. This pins the bug the projection fixed: @@ -9,6 +9,8 @@ import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' +import { createSystemMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -21,20 +23,21 @@ const UI_EXPECTED = fileURLToPath(new URL('./expected/stats-paged-history/ui.exp const MODE = webSnapshotMode() const SEED_ID = 'stats-paged-history-web-e2e' -/** Turn count: 2 surface messages per turn, so 28 turns overflow one 50-message page. */ +/** Turn count: 2 chat messages per turn, so 28 turns overflow one 50-message page. */ const TURNS = 28 const FULL_COUNTS = `${TURNS} turns ${TURNS} steps` /** * Generate the seed: TURNS closed single-step turns of one short user prompt - * and one short assistant reply each. Times are fixed so the fixture is - * byte-deterministic; message ids are synthetic uuids (aria normalizes them). + * and one short assistant reply each. Fixed times pin displayed dates; + * the empty system head precedes every user message in the current format. * @param turns - closed turns to generate. * @returns session.jsonl text for {@link seedSession}. */ function buildSeed(turns: number): string { const lines = [JSON.stringify({ - type: 'session', version: 0, id: '{{sessionId}}', createdAt: 1784974100000, cwd: '{{cwd}}/workspace', + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', + createdAt: 1784974100000, cwd: '{{cwd}}/workspace', isSeeded: false, delegationDepth: 0, })] let seq = 0 let time = 1784974100000 @@ -43,6 +46,14 @@ function buildSeed(turns: number): string { } for (let turn = 1; turn <= turns; turn++) { at({ type: 'turn/start', data: { turn } }) + at({ type: 'step/start', data: { turn, step: 1 } }) + if (turn === 1) { + at({ + type: 'system/message', + data: { turn, step: 1, message: createSystemMessage('', '@deepseek-ai/dsh-system-prompt') }, + surfaceOp: 'append', + }) + } at({ type: 'user/message', data: { @@ -53,10 +64,10 @@ function buildSeed(turns: number): string { }, surfaceOp: 'append', }) - at({ type: 'step/start', data: { turn, step: 1 } }) at({ type: 'assistant/message', data: { + stream: [], turn, step: 1, message: { @@ -66,7 +77,6 @@ function buildSeed(turns: number): string { source: { kind: 'model', provider: 'snapshot', model: 'snapshot-replier' }, }, }, - sourceEventSeqs: [], surfaceOp: 'append', }) at({ type: 'step/end', data: { turn, step: 1 } }) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index f9581e0a09..0f7027be11 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -19,7 +19,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/steering', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') // Two goldens pin the transient Host projection and its durable handoff: the // mid-turn state renders accepted steering from the Session control queue while the // question blocks admission, then the settled state renders the same message @@ -186,7 +186,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v2.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md', + 'session.v3.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 54e9960836..e4a93fce77 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -19,7 +19,7 @@ import { } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' -const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v2.jsonl', import.meta.url)) +const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url)) const AVAILABLE_CHILD_EXPANDED_EXPECTED = fileURLToPath( new URL('../../../snapshots/web/subagent-conversation/ui-expanded.expected.md', import.meta.url), diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index ba14833ef4..11d96c63fd 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -27,7 +27,7 @@ import { } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' -const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v2.jsonl', import.meta.url)) +const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/subagent-interrupt', import.meta.url)) const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md') diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 1ae0f8be62..0a176a9af0 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -23,7 +23,7 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') // Three goldens for the same message: parked mid-turn, aborted, and completed. const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') @@ -64,7 +64,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { /** Boot scaffold + page, materializing the sidecar before the replay row installs. */ async function launch( buildOverride?: (sidecarHome: string) => ReplayOverrideDoc, - paceMs?: number, + // Throughput snapshots require a nonzero interval between replayed chunks. + paceMs = 1, ): Promise { sessionEvents = [] let overridePath: string | undefined @@ -80,7 +81,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { replayFixture: FIXTURE, ...(overridePath === undefined ? {} : { replayOverride: overridePath }), compareReplaySession: overridePath === undefined, - ...(paceMs === undefined ? {} : { paceMs }), + paceMs, }, ) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) @@ -291,7 +292,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await assertFixtureInventory( SNAPSHOT_DIR, [ - 'completed.expected.md', 'focused.expected.md', 'running.expected.md', 'session.v2.jsonl', + 'completed.expected.md', 'focused.expected.md', 'running.expected.md', 'session.v3.jsonl', 'settled.expected.md', 'usage-expanded.expected.md', ], ) diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index 7ffde8594f..4128d0d46e 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -19,7 +19,7 @@ import { import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/web-search-round', import.meta.url)) -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.v2.jsonl', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/web-search-round/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() const QUERIES = ['DeepSeek Harness snapshot search', 'DeepSeek Harness multi-query search'] as const @@ -323,6 +323,6 @@ describe('web e2e: shipped default web search', () => { it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index 5001232549..d665afddb6 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -22,8 +22,8 @@ const MODE = webSnapshotMode() const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/workflow-run', import.meta.url)) const UI_LIVE_EXPECTED = join(SNAPSHOT_DIR, 'ui-live.expected.md') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') -const PARENT_FIXTURE = join(REPO_ROOT, 'snapshots/session/workflow-run/session.v2.jsonl') -const CHILD_FIXTURE = join(REPO_ROOT, 'snapshots/session/workflow-run/session.1.v2.jsonl') +const PARENT_FIXTURE = join(REPO_ROOT, 'snapshots/session/workflow-run/session.v3.jsonl') +const CHILD_FIXTURE = join(REPO_ROOT, 'snapshots/session/workflow-run/session.1.v3.jsonl') const CHILD_PROMPT = 'Reply with exactly the word WF_CHILD_OK and nothing else.' describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () => { @@ -114,11 +114,13 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await runDisclosure.press('Space') expect(await disclosures.count()).toBe(2) expect(await phaseDisclosure.getAttribute('aria-expanded')).toBe('true') - await member.focus() - const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color) await page.setViewportSize({ width: 560, height: 800 }) await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) + // Exercise keyboard focus after the responsive layout has changed. + await phaseDisclosure.focus() + await phaseDisclosure.press('Tab') + await expect.poll(() => member.evaluate(element => element.matches(':focus-visible'))).toBe(true) const darkNarrow = await page.locator('[data-workflow-run]').evaluate((element) => { const panel = element as HTMLElement panel.style.width = '356px' diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 99bce35b56..4163b8058a 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -28,7 +28,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/workspace-management', import.meta.url)) // The seed is another scenario's committed fixture, reused read-only: this // spec needs any one cold session row, not new recorded content. -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' @@ -57,7 +57,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff await dialog.getByRole('button', { name: 'Edit path' }).click() const pathInput = dialog.locator('input[aria-label="Edit path"]') await pathInput.fill(path) - await pathInput.press('Enter') + // Enter's keydown can retire the editor before keyup; target the focused keyboard, not that retiring node. + await page.keyboard.press('Enter') + await pathInput.waitFor({ state: 'detached', timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Edit path', exact: true }).waitFor() return dialog } @@ -85,9 +88,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff } /** - * Adopt an existing directory, waiting for the adoption to settle host-side - * (workspace registered + the flow's New-Session agent up), so later test - * steps can't race the in-flight blank-session attach. + * Adopt an existing directory. Fresh-agent callers also wait for the + * browser's Session switch and composer focus before starting another flow. */ async function adoptDirectory(path: string, options: { waitForAgent?: boolean } = {}): Promise { const agentsBefore = scaffold.ctx.agents.list().length @@ -105,6 +107,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff if (options.waitForAgent === true) { await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 }) .toBeGreaterThan(agentsBefore) + // Host publication precedes the create RPC response. A late Session + // switch focuses the composer and cancels an open path editor on blur. + await expect.poll( + () => page.locator('[data-composer-input][contenteditable="true"]') + .evaluate(element => element === document.activeElement), + { timeout: 10_000 }, + ).toBe(true) } } diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts index 8e45770e2f..21bdd58871 100644 --- a/apps/web/tests/workspace-new-session-folding.e2e.ts +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -20,7 +20,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const EXPECTED_DIR = fileURLToPath(new URL('./expected/workspace-new-session-folding', import.meta.url)) const SIDEBAR_EXPECTED = join(EXPECTED_DIR, 'sidebar.expected.md') -const SEED = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol/session.v2.jsonl', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() const EXISTING_SESSION_COUNT = 6 diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8299e0b6da..8001c0a9c3 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -28,6 +28,7 @@ "tests/startup-rpc-budget.e2e.ts", "tests/minimal-preset.snapshot.ts", "tests/message-feedback-protocol.snapshot.ts", + "tests/preset-migration.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", @@ -39,9 +40,11 @@ "tests/trajectory-virtualization.e2e.ts", "tests/lifecycle-chrome.e2e.ts", "tests/details-session-lifecycle.e2e.ts", + "tests/document-preview.e2e.ts", "tests/plugin-config.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", + "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", "tests/github-ready-review.e2e.ts", "tests/streaming-fence-highlight.e2e.ts", @@ -60,6 +63,7 @@ "tests/rail-search-expand.e2e.ts", "tests/conversation-column-overflow.e2e.ts", "tests/ptc-round.e2e.ts", + "tests/present.e2e.ts", "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index dff22a99ec..0e15b4f088 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -46,15 +46,21 @@ function rejectStandaloneServe(): Plugin { */ function emitPreviewPage(): Plugin { let bootstrapFile: string | undefined + let write = true return { name: 'dsh-emit-preview-page', + configResolved(config) { + write = config.build.write + }, generateBundle(_options, bundle) { + if (!write) return for (const item of Object.values(bundle)) { if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName } if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle') }, async closeBundle() { + if (!write) return // A build that failed before generateBundle has no page to splice. if (bootstrapFile === undefined) return const page = await readFile(src('./dist/index.html'), 'utf8') diff --git a/benchmarks/agent-continuation/README.i18n.yaml b/benchmarks/agent-continuation/README.i18n.yaml index 1db841dc14..68bb7e50e6 100644 --- a/benchmarks/agent-continuation/README.i18n.yaml +++ b/benchmarks/agent-continuation/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 benchmarks/agent-continuation/README.md -README.md: 5f76b67805a906ea13321ab22eb671d41bfc3190 -README.zh.md: 54d9e1a0e950d14ce5a01568bc9e1cdb61de597a +README.md: cb3d99d749922e08d493f0b58dab04c63356024a +README.zh.md: 30331aa886d75f8582c6e386cbb022bb487dbe9f diff --git a/benchmarks/agent-continuation/README.md b/benchmarks/agent-continuation/README.md index 5f76b67805..cb3d99d749 100644 --- a/benchmarks/agent-continuation/README.md +++ b/benchmarks/agent-continuation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) ## Summary -Measure long-history request processing, cold tool-heavy continuation, and repeated discovery of inactive fork children without network services or recorded user data. The SDK variant drives 100 turns and 800 real file reads through the shipped sdk-minimal profile; other cases isolate backend service costs. No case renders a browser. +Measure long-history request processing, cold tool-heavy continuation, and repeated discovery of inactive fork children without network services or recorded user data. The SDK variant drives 100 turns and 800 real file reads through the shipped sdk-minimal profile with an explicit editor patch; other cases isolate backend service costs. No case renders a browser. ## Table of Contents @@ -24,7 +24,7 @@ The test reports all five fresh-process samples and enforces reviewed median bud ## Measurements -[workload.ts](workload.ts) owns synthetic dimensions. [The Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md) owns timing endpoints, calibration evidence, memory interpretation, and exclusions. The model adapter does not perform provider serialization or network calls; integrated cases run synthetic tool bodies through the real tool-execution pipeline, while the SDK profile variant performs real file reads. +[workload.ts](workload.ts) owns synthetic dimensions. Its current-generation history reserves an empty system head in the first step before user input, so resumed prompts replace that head without moving historical messages. [The Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md) owns timing endpoints, calibration evidence, memory interpretation, and exclusions. The model adapter does not perform provider serialization or network calls; integrated cases run synthetic tool bodies through the real tool-execution pipeline, while the SDK profile variant performs real file reads. ## Dev Note diff --git a/benchmarks/agent-continuation/README.zh.md b/benchmarks/agent-continuation/README.zh.md index 54d9e1a0e9..30331aa886 100644 --- a/benchmarks/agent-continuation/README.zh.md +++ b/benchmarks/agent-continuation/README.zh.md @@ -4,7 +4,7 @@ ## Summary -在不使用网络服务或录制用户数据的情况下,测量长历史请求处理、冷工具密集续聊和重复发现非活动 fork 子会话。SDK 变体通过已发布 sdk-minimal profile 执行 100 个轮次和 800 次真实文件读取;其他用例隔离后端服务成本。所有用例均不渲染浏览器。 +在不使用网络服务或录制用户数据的情况下,测量长历史请求处理、冷工具密集续聊和重复发现非活动 fork 子会话。SDK 变体通过已发布 sdk-minimal profile 和显式 editor patch 执行 100 个轮次和 800 次真实文件读取;其他用例隔离后端服务成本。所有用例均不渲染浏览器。 ## Table of Contents @@ -24,7 +24,7 @@ ## 测量 -[workload.ts](workload.ts)拥有合成维度。[Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md)拥有计时终点、校准证据、内存解释和排除项。模型适配器不执行服务商序列化或网络调用;集成用例通过真实工具执行管线运行合成工具体,SDK profile 变体则执行真实文件读取。 +[workload.ts](workload.ts)拥有合成维度。其当前代历史在首个 step 的用户输入之前保留空 system 头节点,因此续聊提示会替换该头节点而不移动历史消息。[Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md)拥有计时终点、校准证据、内存解释和排除项。模型适配器不执行服务商序列化或网络调用;集成用例通过真实工具执行管线运行合成工具体,SDK profile 变体则执行真实文件读取。 ## Dev Note diff --git a/benchmarks/agent-continuation/profile-continuation.worker.ts b/benchmarks/agent-continuation/profile-continuation.worker.ts index 4e0fade29b..531fc2f50d 100644 --- a/benchmarks/agent-continuation/profile-continuation.worker.ts +++ b/benchmarks/agent-continuation/profile-continuation.worker.ts @@ -1,4 +1,4 @@ -/** End-to-end SDK continuation through the built dsh sdk-minimal profile and real file tools. */ +/** End-to-end SDK continuation through built dsh sdk-minimal with an explicitly mounted file editor. */ import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' @@ -27,7 +27,10 @@ async function run(root: string): Promise { await writeFile(patch, [ '- id: llm-deepseek', ' disabled: true', '- id: sessions', ' config:', ' root: ' + JSON.stringify(join(root, 'profile-sessions')), ' compression: zstd', - '- insert:', ' - id: benchmark-model', ' name: ' + JSON.stringify(join(import.meta.dirname, 'profile-adapter.js')), + '- insert:', + ' - id: fs-local', " name: '@deepseek-ai/dsh-fs-local'", + ' - id: str-replace-editor', " name: '@deepseek-ai/dsh-tool-str-replace-editor'", + ' - id: benchmark-model', ' name: ' + JSON.stringify(join(import.meta.dirname, 'profile-adapter.js')), '', ].join('\n')) const env: NodeJS.ProcessEnv = { diff --git a/benchmarks/agent-continuation/synthetic-history.bench.ts b/benchmarks/agent-continuation/synthetic-history.bench.ts new file mode 100644 index 0000000000..6329db885c --- /dev/null +++ b/benchmarks/agent-continuation/synthetic-history.bench.ts @@ -0,0 +1,39 @@ +/** Current-generation benchmark seeds retain the system head across continuation. */ +import { expect, it } from 'vitest' +import { createSystemMessage } from '@deepseek-ai/dsh-llm' +import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { syntheticHistory as browserHistory } from '../long-session-browser/synthetic-history.ts' +import { syntheticHistory } from './workload.ts' + +const header = { type: 'session', version: SESSION_FORMAT_VERSION, id: 'benchmark-seed-check', createdAt: 1_700_000_000_000, cwd: '/bench', isSeeded: false, delegationDepth: 0 } + +for (const [name, generate] of [ + ['continuation', () => [JSON.stringify(header), ...syntheticHistory(2).map(event => JSON.stringify(event))].join('\n')], + ['browser', browserHistory], +] as const) { + it(name + ' seed preserves a protected head when the next prompt replaces it', () => { + const events = parseSessionLog(generate()) + expect(events.slice(0, 4).map(event => event.type)).toEqual(['turn/start', 'step/start', 'system/message', 'user/message']) + const session = Session.create(SessionId(header.id), events) + const head = session.surface.nodes[0]! + expect(session.eventAt(head)).toMatchObject({ type: 'system/message', data: { turn: 1, step: 1, message: { role: 'system', content: [] } } }) + const history = session.deriveMessages() + const turn = events.filter(event => event.type === 'turn/start').length + 1 + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + const replacement = session.append('system/message', { + turn, step: 1, message: createSystemMessage('Next synthetic prompt', '@deepseek-ai/dsh-system-prompt'), + }, { surfaceOp: { op: 'replace', startSeq: head, endSeq: head }, sourceEventSeqs: [head] }) + const restored = Session.create(SessionId(header.id), parseSessionLog([ + JSON.stringify(header), ...session.snapshotEvents().map(event => JSON.stringify(event)), + ].join('\n'))) + expect(restored.surface.nodes[0]).toBe(replacement.seq) + expect(restored.deriveMessages()[0]).toMatchObject({ role: 'system', content: [{ type: 'text', text: 'Next synthetic prompt' }] }) + expect(restored.deriveMessages().slice(1)).toEqual(history) + for (const event of events) { + if (event.type === 'session/title') expect(session.eventAt(event.data.messageSeqs[0]!)?.type).toBe('user/message') + if (event.type === 'tool/result') expect(session.eventAt(event.sourceEventSeqs![0]!)?.type).toBe('tool/call') + } + }) +} diff --git a/benchmarks/agent-continuation/workload.ts b/benchmarks/agent-continuation/workload.ts index 6785585574..20eae5d5ca 100644 --- a/benchmarks/agent-continuation/workload.ts +++ b/benchmarks/agent-continuation/workload.ts @@ -68,6 +68,9 @@ export function syntheticHistory(turns: number): SessionEvent[] { for (let turn = 1; turn <= turns; turn++) { session.append('turn/start', { turn }) session.append('step/start', { turn, step: 1 }) + if (turn === 1) session.append('system/message', { + turn, step: 1, message: { id: MessageId('system-head'), role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }, + }, { surfaceOp: 'append' }) session.append('user/message', { id: MessageId('prompt-' + String(turn)), role: 'user', content: [{ type: 'text', text: 'Inspect synthetic module ' + String(turn) }], source: { kind: 'user' }, diff --git a/benchmarks/long-session-browser/README.i18n.yaml b/benchmarks/long-session-browser/README.i18n.yaml index dfb24532ca..2a5f750328 100644 --- a/benchmarks/long-session-browser/README.i18n.yaml +++ b/benchmarks/long-session-browser/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write benchmarks/long-session-browser/README.md -README.md: 6b6777687947bee42337568ad3748e37a563be87 -README.zh.md: 139bd237d45275ea165a3e90f055f8ce507c5c53 +README.md: 009a7a61d9a1a0d4b198ae017def5d3126b713a2 +README.zh.md: 2dd0c52b0f1cc04aac8a78d847401067201afa13 diff --git a/benchmarks/long-session-browser/README.md b/benchmarks/long-session-browser/README.md index 6b67776879..009a7a61d9 100644 --- a/benchmarks/long-session-browser/README.md +++ b/benchmarks/long-session-browser/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This reference describes the required Chromium workflow in [long-session.bench.ts](long-session.bench.ts). It opens a synthetic 240-turn Session, loads every older page, visits Trajectory, returns to Chat, and submits a paced reply while typing another draft. The shipped Web scaffold owns the isolated home, persistence, replay adapter, and loopback listener; Chromium loads the built Web artifacts, not a replacement development server. +The required Chromium workflow in [long-session.bench.ts](long-session.bench.ts) measures opening a synthetic 240-turn Session, loading every older page, visiting Trajectory, and typing another draft during a paced reply. The shipped Web scaffold owns the isolated home, persistence, replay adapter, and loopback listener; Chromium loads the built Web artifacts, not a replacement development server. ## Run @@ -10,8 +10,8 @@ This reference describes the required Chromium workflow in [long-session.bench.t ## Measurements -Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Send lookup is scoped to the composer seat; reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before Send, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets. +Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Enter submits from the focused composer; draft typing retains that focus without a mouse click. Reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before submission, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. Diagnostics report marker state and focus after the first-visible wait, and browser-clock timestamps and focus at the first input event. They do not pause replay; a delayed first observation or input can still fail overlap. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets. -The fixture contains mixed-language prompts, prose, reasoning, 20 code fences, and 40 synthetic tool results. Every historical Assistant includes a compact stream built by the production accumulator from matching reasoning, text, tool arguments, usage, and finish chunks. No model, tool, external network, recorded Session, or private Harness home supplies its content. Streaming uses 120 text deltas at 16 ms replay pacing through the real composer, agent loop, transport, and persistence. +The fixture reserves an empty system head before the first user message, with each user message inside its step. It contains mixed-language prompts, prose, reasoning, 20 code fences, and 40 synthetic tool results. Every historical Assistant includes a compact stream built by the production accumulator from matching reasoning, text, tool arguments, usage, and finish chunks. No model, tool, external network, recorded Session, or private Harness home supplies its content. Streaming uses 120 text deltas at 16 ms replay pacing through the real composer, agent loop, transport, and persistence. The [decision record](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md) owns calibration, exclusions, and alternatives. The larger [manual diagnostic](../../apps/web/tests/complex-history.perf.ts) remains separate. diff --git a/benchmarks/long-session-browser/README.zh.md b/benchmarks/long-session-browser/README.zh.md index 139bd237d4..2dd0c52b0f 100644 --- a/benchmarks/long-session-browser/README.zh.md +++ b/benchmarks/long-session-browser/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本文说明 [long-session.bench.ts](long-session.bench.ts) 中必需的 Chromium 工作流。它打开一个合成的 240 轮 Session,加载所有更早的分页,访问 Trajectory,返回 Chat,并在流式回复期间输入下一条草稿。随产品维护的 Web scaffold 拥有隔离的主目录、持久化、重放适配器和回环监听器;Chromium 加载构建后的 Web 产物,而非替代开发服务器。 +[long-session.bench.ts](long-session.bench.ts) 中必需的 Chromium 工作流测量打开合成的 240 轮 Session、加载所有更早的分页、访问 Trajectory,以及在有节奏的流式回复期间输入下一条草稿。随产品维护的 Web scaffold 拥有隔离的主目录、持久化、重放适配器和回环监听器;Chromium 加载构建后的 Web 产物,而非替代开发服务器。 ## 运行 @@ -10,8 +10,8 @@ ## 测量 -三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期对话状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首段可见回复、真实草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。发送控件查找限定在 composer seat;回复标记查找与输入事件文本证据仅读取最新 Assistant step,避免重复扫描全部历史文本与无障碍属性。输入观察器在发送前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。实际首个输入事件必须观察到未完成的回复;完成测量在 Host 结算后等待新 turn-tail 渲染。测量后,在 DONE 之后发送的真实按键必须无法通过同一个重叠断言。打开、最慢更早分页和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。 +三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期对话状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首段可见回复、真实草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。Enter 从已聚焦的输入框提交;草稿键入保留该焦点,不执行鼠标点击。回复标记查找与输入事件文本证据仅读取最新 Assistant step,避免重复扫描全部历史文本与无障碍属性。输入观察器在提交前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。诊断报告首段可见等待后的标记状态与焦点,以及首个输入事件的浏览器时钟时间戳与焦点。诊断不会暂停重放;首段观察或输入延迟仍可能导致重叠失败。实际首个输入事件必须观察到未完成的回复;完成测量在 Host 结算后等待新 turn-tail 渲染。测量后,在 DONE 之后发送的真实按键必须无法通过同一个重叠断言。打开、最慢更早分页和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。 -fixture(测试前置数据)包含混合语言提示、正文、推理、20 个代码块和 40 个合成工具结果。每条历史 Assistant 都含紧凑 stream,由生产 accumulator 从匹配的推理、文本、工具参数、usage 和 finish chunk 构建。其内容不来自模型、工具、外部网络、录制 Session 或私有 Harness 主目录。流式回复以 16 ms 重放间隔发送 120 个文本 delta,经过真实输入框、agent loop(智能体循环)、传输与持久化。 +fixture(测试前置数据)在首条用户消息前保留空 system 头节点,每条用户消息都位于其 step 内。它包含混合语言提示、正文、推理、20 个代码块和 40 个合成工具结果。每条历史 Assistant 都含紧凑 stream,由生产 accumulator 从匹配的推理、文本、工具参数、usage 和 finish chunk 构建。其内容不来自模型、工具、外部网络、录制 Session 或私有 Harness 主目录。流式回复以 16 ms 重放间隔发送 120 个文本 delta,经过真实输入框、agent loop(智能体循环)、传输与持久化。 [决策记录](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md)拥有校准、排除项与替代方案。更大规模的[手动诊断](../../apps/web/tests/complex-history.perf.ts)保持独立。 diff --git a/benchmarks/long-session-browser/long-session.bench.ts b/benchmarks/long-session-browser/long-session.bench.ts index 789ebeea9a..c43ec05814 100644 --- a/benchmarks/long-session-browser/long-session.bench.ts +++ b/benchmarks/long-session-browser/long-session.bench.ts @@ -56,10 +56,12 @@ async function watchInputOverlap(composer: Locator): Promise { await composer.evaluate((element, markers) => { element.removeAttribute('data-benchmark-input-witness') element.removeAttribute('data-benchmark-input-overlap') + element.removeAttribute('data-benchmark-input-timing') element.addEventListener('input', (event) => { const transcript = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1)?.textContent ?? '' element.setAttribute('data-benchmark-input-overlap', String(event.isTrusted && transcript.includes(markers.first) && !transcript.includes(markers.done))) element.setAttribute('data-benchmark-input-witness', JSON.stringify({ trusted: event.isTrusted, first: transcript.includes(markers.first), done: transcript.includes(markers.done) })) + element.setAttribute('data-benchmark-input-timing', JSON.stringify({ atMs: window.performance.now(), eventAtMs: event.timeStamp, focused: document.activeElement === element })) }, { once: true }) }, { first: FIRST, done: DONE }) } @@ -154,18 +156,21 @@ it('opens, pages, navigates and streams into a 240-turn browser history', async ) await watchInputOverlap(composer) const started = performance.now() - await page.locator('[data-composer-seat]').getByRole('button', { name: 'Send message', exact: true }).click() + await page.keyboard.press('Enter') const reply = page.locator('[data-chat-flow-kind="assistant-step"]').last() await reply.getByText(FIRST, { exact: false }).last().waitFor() const first = performance.now() - started - // Observe the actual trusted input event, not state before asynchronous click/typing. + const firstObservation = await composer.evaluate((element, markers) => { + const transcript = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1)?.textContent ?? '' + return { atMs: window.performance.now(), focused: document.activeElement === element, first: transcript.includes(markers.first), done: transcript.includes(markers.done) } + }, { first: FIRST, done: DONE }) + // Keep focus across submission; mouse actionability must not delay the input probe. const input = await measure(page, async () => { - await composer.click() await page.keyboard.type('next synthetic question') await expect.poll(() => composer.textContent()).toBe('next synthetic question') }) const inputOverlapped = await composer.getAttribute('data-benchmark-input-overlap') === 'true' - console.log(JSON.stringify({ benchmark: 'long-session-browser/input', sample, first, input, witness: await composer.getAttribute('data-benchmark-input-witness') })) + console.log(JSON.stringify({ benchmark: 'long-session-browser/input', sample, first, input, firstObservation, witness: await composer.getAttribute('data-benchmark-input-witness'), inputTiming: await composer.getAttribute('data-benchmark-input-timing') })) expectInputOverlap(inputOverlapped) await reply.getByText(DONE, { exact: false }).last().waitFor() const settlement = await settled diff --git a/benchmarks/long-session-browser/synthetic-history.ts b/benchmarks/long-session-browser/synthetic-history.ts index 106c9950a3..32885489cb 100644 --- a/benchmarks/long-session-browser/synthetic-history.ts +++ b/benchmarks/long-session-browser/synthetic-history.ts @@ -1,5 +1,5 @@ /** Synthetic current-generation history and paced reply for browser measurements. */ -import { createAssistantMessage, createUserMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import { createAssistantMessage, createSystemMessage, createUserMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' @@ -26,12 +26,15 @@ export function syntheticHistory(): string { const session = Session.create(SessionId(SESSION_ID)) for (let turn = 1; turn <= HISTORY_TURNS; turn++) { session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + if (turn === 1) session.append('system/message', { + turn, step: 1, message: createSystemMessage('', '@deepseek-ai/dsh-system-prompt'), + }, { surfaceOp: 'append' }) const user = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Review synthetic change ' + String(turn) + ': 检查增量渲染。 '.repeat(30) }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) if (turn === 1) session.append('session/title', { title: TITLE, messageSeqs: [user.seq], source: { kind: 'fallback' } }) - session.append('step/start', { turn, step: 1 }) const callId = ToolCallId('synthetic-tool-' + String(turn)) const tool = turn % 6 === 0 const code = turn % 12 === 0 diff --git a/benchmarks/session-open/session-open.bench.ts b/benchmarks/session-open/session-open.bench.ts index 5a68eb1445..a7897f8940 100644 --- a/benchmarks/session-open/session-open.bench.ts +++ b/benchmarks/session-open/session-open.bench.ts @@ -1,9 +1,12 @@ /** Required performance budgets for cold Session preparation, first history, and Agent resume. */ -import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises' +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { runBuiltBenchmarkWorker, type BuiltBenchmarkWorkerRun, @@ -18,6 +21,7 @@ import type { } from './session-open.worker.ts' import { SYNTHETIC_CURRENT_GENERATION, + SYNTHETIC_SESSION_ID, SYNTHETIC_SESSION_DIRECTORY, SYNTHETIC_CURRENT_FILENAME, SYNTHETIC_V0_FILENAME, @@ -152,7 +156,10 @@ function requireReport( if (run.report !== undefined) return run.report const stderrLines = run.stderr.trim().split('\n') const fatal = stderrLines.filter(line => /FATAL ERROR|heap limit|out of memory/i.test(line)) - const detail = (fatal.length > 0 ? fatal : stderrLines.slice(-10)).join('\n') + const context = stderrLines.length <= 20 + ? stderrLines + : [...stderrLines.slice(0, 10), '... stderr middle omitted ...', ...stderrLines.slice(-10)] + const detail = (fatal.length > 0 ? fatal : context).join('\n') const limit = heapLimitMb === undefined ? 'normal heap' : `${String(heapLimitMb)} MB old space` throw new Error( `${scenario} failed under ${limit}: exit=${String(run.exitCode)}, signal=${String(run.signal)}, ` @@ -293,6 +300,67 @@ describe('standard hosted reopen calibration', () => { }) }) +describe('Session opening benchmark prerequisites', () => { + it('retains the exception headline and bounded stderr tail when a worker fails', () => { + const headline = 'SessionFormatUnsupportedError: source chronology cannot be migrated' + const stderr = [headline, ...Array.from({ length: 30 }, (_, index) => 'stack frame ' + String(index)), 'Node.js test'].join('\n') + const run: WorkerRun = { report: undefined, exitCode: 1, signal: null, timedOut: false, stderr } + expect(() => requireReport(run, 'agent-resume')).toThrow(headline) + expect(() => requireReport(run, 'agent-resume')).toThrow('Node.js test') + expect(() => requireReport(run, 'agent-resume')).not.toThrow('stack frame 15') + expect(() => requireReport({ ...run, stderr: 'FATAL ERROR: heap limit' }, 'agent-resume', 128)) + .toThrow('128 MB old space') + }) + + it('migrates the generated workload and reopens its successor without changing V0', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-session-bench-fixture-')) + const contexts: Context[] = [] + const mount = async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' }) + return ctx.sessionPersistence + } + try { + const facts = await writeSyntheticReleasedV0Session(root, { turns: 2, textDeltas: 12 }) + expect({ events: facts.events, rows: facts.rows, frames: facts.frames }).toEqual({ events: 54, rows: 28, frames: 29 }) + const original = await readFile(facts.path) + const directory = join(root, SYNTHETIC_SESSION_DIRECTORY) + const persistence = await mount() + const read = await persistence.open(SessionId(SYNTHETIC_SESSION_ID), 'read') + const initial = await read.read() + const session = Session.fromRestore(read.header.id, initial.events, read.header, read.inheritedEventCount, initial.eventState) + expect(read.header.version).toBe(SESSION_FORMAT_VERSION) + expect(initial.events.filter(event => event.type === 'system/message')).toHaveLength(1) + expect(session.deriveMessages().map(({ id, role, content }) => ({ id, role, content }))).toEqual( + [1, 2].flatMap(turn => [ + { id: 'user-' + String(turn), role: 'user', content: [{ type: 'text', text: 'prompt ' + String(turn) }] }, + { id: 'assistant-' + String(turn), role: 'assistant', content: [ + { type: 'reasoning', text: 'r0 r1 r2 ' }, + { type: 'text', text: Array.from({ length: 12 }, (_, index) => 'w' + String(index) + ' ').join('') }, + ] }, + ]), + ) + await read.close() + expect(await readdir(directory)).toEqual([SYNTHETIC_V0_FILENAME]) + const writer = await persistence.open(SessionId(SYNTHETIC_SESSION_ID), 'write') + expect((await writer.read()).events).toEqual(initial.events) + await writer.close() + expect(await readdir(directory)).toContain(SYNTHETIC_CURRENT_FILENAME) + const reopened = await (await mount()).open(SessionId(SYNTHETIC_SESSION_ID), 'read') + expect((await reopened.read()).events).toEqual(initial.events) + await reopened.close() + expect(await readFile(facts.path)).toEqual(original) + } finally { + try { + for (const ctx of contexts.reverse()) await ctx.fiber.dispose() + } finally { + await rm(root, { recursive: true, force: true }) + } + } + }) +}) + describe('opening a large Session for first open and post-upgrade reopen', () => { const suite = new SessionOpenBenchmarkSuite() diff --git a/benchmarks/session-open/synthetic-released-v0-session.ts b/benchmarks/session-open/synthetic-released-v0-session.ts index f740bb1b71..e10bc70881 100644 --- a/benchmarks/session-open/synthetic-released-v0-session.ts +++ b/benchmarks/session-open/synthetic-released-v0-session.ts @@ -67,13 +67,13 @@ class ReleasedV0FixtureBuilder { private appendTurn(turn: number, reasoningDeltaCount: number, textDeltaCount: number): void { this.appendEvent('turn/start', { turn }) + this.appendEvent('step/start', { turn, step: 1 }) this.appendEvent('user/message', { id: `user-${String(turn)}`, role: 'user', content: [{ type: 'text', text: `prompt ${String(turn)}` }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - this.appendEvent('step/start', { turn, step: 1 }) const firstChunkSeq = this.appendChunk(turn, { type: 'block-start', index: 0, blockType: 'reasoning' }) const reasoningDeltas = Array.from( { length: reasoningDeltaCount }, diff --git a/docs/agent-lifecycle.i18n.yaml b/docs/agent-lifecycle.i18n.yaml index 6d04f78fd5..0a03ee7f38 100644 --- a/docs/agent-lifecycle.i18n.yaml +++ b/docs/agent-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/agent-lifecycle.md -agent-lifecycle.md: afe660051e5d3ce4ae40f5d4fd32e6ff47c99dba -agent-lifecycle.zh.md: ad81fbefe7afae5de4c497aa6522b1a5fcf54fe7 +agent-lifecycle.md: 6ffe3c2b47e766ac985b3192a1c08787821fc46e +agent-lifecycle.zh.md: c4b2fa70dbdb0487c3871b6e65227eac8b062e24 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index afe660051e..6ffe3c2b47 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -25,23 +25,30 @@ sequenceDiagram Note over Agent,Driver: claim pending next-step input plus one queued prompt Driver-->>SDK: agent/inbox/spliced pure deletion Driver-->>SDK: agent/inbox/claimed { message, turn } per message + Driver->>Prompt: system-prompt/assemble waterfall Driver->>Hooks: agent/pre-step waterfall Hooks-->>Driver: authoritative reject or enter(messages) - alt proposed step rejected or pre-step failed + alt proposed step rejected, first batch empty, or pre-step failed Driver-->>Driver: claimed batch stays removed, the open turn spends no step else enter proposed step Driver->>Session: step/start + Driver->>Hooks: agent/request waterfall + Driver->>LLM: prepareCall(config, signal) + Note over Driver,LLM: cancellation during either async phase commits neither system nor users + Note over Driver,Session: synchronous admission using the prepared call capability + Driver->>Session: system/message ordered per-node reconciliation Driver->>Session: user/message per entered message - Driver->>Prompt: system-prompt/assemble waterfall - Driver->>LLM: agent/request waterfall, then llm/stream waterfall + Driver->>Session: request/header and request/context as needed + Driver->>Driver: derive and freeze request from the log + Driver->>LLM: bound prepared call through llm/stream waterfall LLM-->>Driver: StreamChunk* Driver-->>SDK: agent/assistant-stream chunk* alt final adapter or terminal in-band request failure Driver->>Session: assistant/attempt Driver-->>SDK: agent/assistant-stream committed end - Driver->>Session: step/end Driver->>Hooks: agent/request-error waterfall Hooks-->>Driver: return retry action or preserve the original error + Note over Driver,LLM: retry in the open step: prepare and reconcile the same rendered assembly without repeating pre-step or users else model request succeeded Driver->>Session: assistant/message Driver-->>SDK: agent/assistant-stream committed end @@ -75,7 +82,7 @@ sequenceDiagram The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes, and embeds the exact compact timed stream. Empty content stays out of derived history. A failed, retried, cancelled, or stream-error attempt that reaches settlement without a surface message records its stream as `assistant/attempt`. Live `agent/assistant-stream` chunk frames are transient; replay reads either durable settlement, and a hard process loss before settlement leaves no durable attempt stream. -`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. +`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery runs within the open step and retries only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. Each retry prepares its call and reconciles the retained rendered assembly before request derivation, without repeating assembly, pre-step, or user admission. The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch. diff --git a/docs/agent-lifecycle.zh.md b/docs/agent-lifecycle.zh.md index ad81fbefe7..c4b2fa70db 100644 --- a/docs/agent-lifecycle.zh.md +++ b/docs/agent-lifecycle.zh.md @@ -27,23 +27,30 @@ sequenceDiagram Note over Agent,Driver: claim pending next-step input plus one queued prompt Driver-->>SDK: agent/inbox/spliced pure deletion Driver-->>SDK: agent/inbox/claimed { message, turn } per message + Driver->>Prompt: system-prompt/assemble waterfall Driver->>Hooks: agent/pre-step waterfall Hooks-->>Driver: authoritative reject or enter(messages) - alt proposed step rejected or pre-step failed + alt proposed step rejected, first batch empty, or pre-step failed Driver-->>Driver: claimed batch stays removed, the open turn spends no step else enter proposed step Driver->>Session: step/start + Driver->>Hooks: agent/request waterfall + Driver->>LLM: prepareCall(config, signal) + Note over Driver,LLM: cancellation during either async phase commits neither system nor users + Note over Driver,Session: synchronous admission using the prepared call capability + Driver->>Session: system/message ordered per-node reconciliation Driver->>Session: user/message per entered message - Driver->>Prompt: system-prompt/assemble waterfall - Driver->>LLM: agent/request waterfall, then llm/stream waterfall + Driver->>Session: request/header and request/context as needed + Driver->>Driver: derive and freeze request from the log + Driver->>LLM: bound prepared call through llm/stream waterfall LLM-->>Driver: StreamChunk* Driver-->>SDK: agent/assistant-stream chunk* alt final adapter or terminal in-band request failure Driver->>Session: assistant/attempt Driver-->>SDK: agent/assistant-stream committed end - Driver->>Session: step/end Driver->>Hooks: agent/request-error waterfall Hooks-->>Driver: return retry action or preserve the original error + Note over Driver,LLM: retry in the open step: prepare and reconcile the same rendered assembly without repeating pre-step or users else model request succeeded Driver->>Session: assistant/message Driver-->>SDK: agent/assistant-stream committed end @@ -77,7 +84,7 @@ sequenceDiagram `assistant/message` 事件会记录每次成功的提供方调用,包括返回空内容或以 `max-tokens` 结束的调用,并嵌入精确的紧凑带时间 stream。空内容不会进入派生历史。失败、重试、取消或 stream error attempt 到达 settlement 时,如果没有 surface message,就会把 stream 记录为 `assistant/attempt`。实时 `agent/assistant-stream` chunk frame 是瞬态数据;回放读取任一种持久 settlement,如果进程在 settlement 前硬中断,则不会留下持久 attempt stream。 -`dsh-compaction-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在失败步骤结束之后、失败轮次结束之前;只有当剪枝或摘要生成推进了 surface replacement generation 时,系统才会开启一个全新的重试轮次,否则仍以原始请求错误为准。 +`dsh-compaction-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在仍打开的步骤内,只有剪枝或摘要生成推进 surface replacement generation 时才重试,否则仍以原始请求错误为准。每次重试都会准备调用,并在派生请求之前协调保留的已渲染组装结果,不重复组装、pre-step 或用户消息准入。 以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息与 `startsRequestSeries`,除非有意替换。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index fd58c7ae00..d9d1bd54f3 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: a77dfd06c61cbb12d7008133a7e8d515a3449a02 -architecture.zh.md: 83fa958609500cc85af9c0d1f7e339104f6c18c7 +architecture.md: fed98f006471f86f02c43bcb0c6ea4dfe7850da8 +architecture.zh.md: b1ec7ddaf0278a14cd7c18d6cc272fc59c5c3781 diff --git a/docs/architecture.md b/docs/architecture.md index a77dfd06c6..fed98f0064 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,13 +84,15 @@ A **step** is one model request plus the tools it calls. A **turn** is zero or m ```text turn/start claim next-step input plus one queued message - assemble prompt sections + tool schemas + assemble prompt sections + tool schemas; project runtime context -> agent/pre-step reject | enter(messages, startsRequestSeries?) reject, or a first enter rewritten empty -> close the turn with no step step/start - append entered messages as user/message - derive model history from the log - agent/request -> llm/stream -> agent/assistant-stream start + agent/request -> prepareCall (cancellation commits neither system nor users) + reconcile system/message using the prepared call capability + append entered messages as user/message; log request/header and request/context as needed + derive and freeze model history from the log + stream the bound prepared call -> llm/stream -> agent/assistant-stream start agent/assistant-stream chunk* assistant/message | assistant/attempt -> agent/assistant-stream end tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result* @@ -100,11 +102,11 @@ turn/start turn/end ``` -`turn/*`, `step/*`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. +`turn/*`, `step/*`, `system/message`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. -`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. +`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). The loop sends immutable requests while keeping cancellation live. It reuses message-freeze provenance only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 83fa958609..b1ec7ddaf0 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -88,13 +88,15 @@ Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该 ```text turn/start claim next-step input plus one queued message - assemble prompt sections + tool schemas + assemble prompt sections + tool schemas; project runtime context -> agent/pre-step reject | enter(messages, startsRequestSeries?) reject, or a first enter rewritten empty -> close the turn with no step step/start - append entered messages as user/message - derive model history from the log - agent/request -> llm/stream -> agent/assistant-stream start + agent/request -> prepareCall (cancellation commits neither system nor users) + reconcile system/message using the prepared call capability + append entered messages as user/message; log request/header and request/context as needed + derive and freeze model history from the log + stream the bound prepared call -> llm/stream -> agent/assistant-stream start agent/assistant-stream chunk* assistant/message | assistant/attempt -> agent/assistant-stream end tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result* @@ -104,11 +106,11 @@ turn/start turn/end ``` -`turn/*`、`step/*`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 +`turn/*`、`step/*`、`system/message`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 -`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 +`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 0b79f5f0a8..bfc5221538 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: d3ea87e94ecb2de64c146c8647684785e537647c -config-catalog.zh.md: 053ca6d1e357c07524b4b993447a59ed69761a88 +config-catalog.md: 50626a7d85aa54f6d2e3cdb6140b3af8081d1461 +config-catalog.zh.md: d2ae35b91a4a83da882e8ec5af82a505aa5d5bd1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d3ea87e94e..50626a7d85 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -246,6 +246,8 @@ export interface Config { * way. The file itself has no size cap: a caller pages through it. */ readonly maxBytes: number + /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */ + readonly maxFileBytes: number /** Default and largest page size in lines; a request asking for more is refused. */ readonly maxLines: number /** Cap on returned directory entries; the rest is dropped and reported cut. */ @@ -253,7 +255,7 @@ export interface Config { } ``` -Source: [`packages/api/workspace-files/src/index.ts:57`](../packages/api/workspace-files/src/index.ts) +Source: [`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts) @@ -1081,10 +1083,16 @@ export interface DeepSeekCatalogModel { imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number + /** + * `'in-history'` declares that the endpoint reads the latest `system` + * message at any position of the conversation as the complete effective + * system prompt; omission means only a leading system message is read. + */ + systemPromptUpdate?: SystemPromptUpdate } ``` -Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) Source: [`packages/llm/llm-deepseek/src/index.ts:125`](../packages/llm/llm-deepseek/src/index.ts) @@ -1361,7 +1369,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable @@ -1433,12 +1441,14 @@ export interface ReplayModelConfig { * {@link reasoningEfforts} or call resolution rejects the route. */ defaultReasoningEffort?: string + /** Optional in-history system prompt replacement for a keyless replay route. */ + systemPromptUpdate?: SystemPromptUpdate } ``` -Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) -Source: [`packages/test-support/llm-replay/src/index.ts:1278`](../packages/test-support/llm-replay/src/index.ts) +Source: [`packages/test-support/llm-replay/src/index.ts:1123`](../packages/test-support/llm-replay/src/index.ts) @@ -1888,7 +1898,7 @@ export interface Config { } ``` -Source: [`packages/session/session-log-deepseek/src/index.ts:36`](../packages/session/session-log-deepseek/src/index.ts) +Source: [`packages/session/session-log-deepseek/src/index.ts:38`](../packages/session/session-log-deepseek/src/index.ts) @@ -2732,7 +2742,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -2861,6 +2871,22 @@ export interface Config { Source: [`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts) + + +## `@deepseek-ai/dsh-tool-present` + +Requires: `tools` · `fs` · `sessionProjections` + +```ts config-catalog +/** Per-call delivery limit. */ +export interface Config { + /** Maximum number of files in one call. */ + maxFiles: number +} +``` + +Source: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts) + ## `@deepseek-ai/dsh-tool-pwsh` @@ -2990,8 +3016,8 @@ export interface Config { */ toolName?: string /** - * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. + * Sample the Host `subagent-model-selection` setting for each new top-level + * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean /** @@ -3041,7 +3067,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) @@ -3433,7 +3459,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) @@ -3455,9 +3481,9 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-sidebar-textpreview` ([`packages/client/ui-sidebar-textpreview/src/index.ts`](../packages/client/ui-sidebar-textpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) @@ -3531,6 +3557,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base` ([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-chunked-list` ([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts)) - `@deepseek-ai/dsh-client-store` ([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-dockkit` ([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts)) @@ -3561,6 +3588,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-session-format-catalog` ([`packages/session/session-format-catalog/src/index.ts`](../packages/session/session-format-catalog/src/index.ts)) - `@deepseek-ai/dsh-session-format-v0-to-v1` ([`packages/session/session-format-v0-to-v1/src/index.ts`](../packages/session/session-format-v0-to-v1/src/index.ts)) - `@deepseek-ai/dsh-session-format-v1-to-v2` ([`packages/session/session-format-v1-to-v2/src/index.ts`](../packages/session/session-format-v1-to-v2/src/index.ts)) +- `@deepseek-ai/dsh-session-format-v2-to-v3` ([`packages/session/session-format-v2-to-v3/src/index.ts`](../packages/session/session-format-v2-to-v3/src/index.ts)) - `@deepseek-ai/dsh-session-snapshot` ([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 053ca6d1e3..d2ae35b91a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -248,6 +248,8 @@ export interface Config { * way. The file itself has no size cap: a caller pages through it. */ readonly maxBytes: number + /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */ + readonly maxFileBytes: number /** Default and largest page size in lines; a request asking for more is refused. */ readonly maxLines: number /** Cap on returned directory entries; the rest is dropped and reported cut. */ @@ -255,7 +257,7 @@ export interface Config { } ``` -来源:[`packages/api/workspace-files/src/index.ts:57`](../packages/api/workspace-files/src/index.ts) +来源:[`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts) @@ -580,7 +582,7 @@ export interface Config { } ``` -来源:[`packages/experimental/agent-team/src/types.ts:124`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) @@ -772,7 +774,7 @@ export interface Config { } ``` -来源:[`packages/fs/fs-local/src/index.ts:41`](../packages/fs/fs-local/src/index.ts) +来源:[`packages/fs/fs-local/src/index.ts:42`](../packages/fs/fs-local/src/index.ts) @@ -824,7 +826,7 @@ export interface Config { } ``` -来源:[`packages/bundle/headless/src/index.ts:33`](../packages/bundle/headless/src/index.ts) +来源:[`packages/bundle/headless/src/index.ts:34`](../packages/bundle/headless/src/index.ts) @@ -1083,10 +1085,16 @@ export interface DeepSeekCatalogModel { imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number + /** + * `'in-history'` declares that the endpoint reads the latest `system` + * message at any position of the conversation as the complete effective + * system prompt; omission means only a leading system message is read. + */ + systemPromptUpdate?: SystemPromptUpdate } ``` -依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) 来源:[`packages/llm/llm-deepseek/src/index.ts:125`](../packages/llm/llm-deepseek/src/index.ts) @@ -1363,7 +1371,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable @@ -1435,12 +1443,14 @@ export interface ReplayModelConfig { * {@link reasoningEfforts} or call resolution rejects the route. */ defaultReasoningEffort?: string + /** Optional in-history system prompt replacement for a keyless replay route. */ + systemPromptUpdate?: SystemPromptUpdate } ``` -依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) -来源:[`packages/test-support/llm-replay/src/index.ts:1294`](../packages/test-support/llm-replay/src/index.ts) +来源:[`packages/test-support/llm-replay/src/index.ts:1123`](../packages/test-support/llm-replay/src/index.ts) @@ -1890,7 +1900,7 @@ export interface Config { } ``` -来源:[`packages/session/session-log-deepseek/src/index.ts:36`](../packages/session/session-log-deepseek/src/index.ts) +来源:[`packages/session/session-log-deepseek/src/index.ts:38`](../packages/session/session-log-deepseek/src/index.ts) @@ -2734,7 +2744,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts) +来源:[`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -2863,6 +2873,22 @@ export interface Config { 来源:[`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts) + + +## `@deepseek-ai/dsh-tool-present` + +依赖: `tools` · `fs` · `sessionProjections` + +```ts config-catalog +/** Per-call delivery limit. */ +export interface Config { + /** Maximum number of files in one call. */ + maxFiles: number +} +``` + +来源: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts) + ## `@deepseek-ai/dsh-tool-pwsh` @@ -2992,8 +3018,8 @@ export interface Config { */ toolName?: string /** - * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. + * Sample the Host `subagent-model-selection` setting for each new top-level + * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean /** @@ -3435,7 +3461,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse`([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) @@ -3457,9 +3483,9 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-sidebar-textpreview`([`packages/client/ui-sidebar-textpreview/src/index.ts`](../packages/client/ui-sidebar-textpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) @@ -3532,6 +3558,7 @@ export interface Config { - `@deepseek-ai/dsh-atomic-write`([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base`([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-chunked-list`([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts)) - `@deepseek-ai/dsh-client-store`([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime`([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-dockkit`([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts)) @@ -3562,6 +3589,7 @@ export interface Config { - `@deepseek-ai/dsh-session-format-catalog`([`packages/session/session-format-catalog/src/index.ts`](../packages/session/session-format-catalog/src/index.ts)) - `@deepseek-ai/dsh-session-format-v0-to-v1`([`packages/session/session-format-v0-to-v1/src/index.ts`](../packages/session/session-format-v0-to-v1/src/index.ts)) - `@deepseek-ai/dsh-session-format-v1-to-v2`([`packages/session/session-format-v1-to-v2/src/index.ts`](../packages/session/session-format-v1-to-v2/src/index.ts)) +- `@deepseek-ai/dsh-session-format-v2-to-v3`([`packages/session/session-format-v2-to-v3/src/index.ts`](../packages/session/session-format-v2-to-v3/src/index.ts)) - `@deepseek-ai/dsh-session-snapshot`([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml new file mode 100644 index 0000000000..a5d8e2ad3c --- /dev/null +++ b/docs/cookbook/adding-a-session-format-version.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md +adding-a-session-format-version.md: c2d3b966c4ca2780e5ddef933f57715721a9dd43 +adding-a-session-format-version.zh.md: 6bb93f5ff92d38acc538c53b0ff5c477bf71915b diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md new file mode 100644 index 0000000000..c2d3b966c4 --- /dev/null +++ b/docs/cookbook/adding-a-session-format-version.md @@ -0,0 +1,109 @@ +# Cookbook: adding a Session log format version + +English | [中文](adding-a-session-format-version.zh.md) + +## Summary + +Use this tutorial to introduce a structural Session log version without rewriting released data. The worked example adds V3 through one V2→V3 edge, then lets independently reviewed changes extend that unreleased edge. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md). + +## Table of Contents + +- [1. Choose the version and release base](#choose-the-version) +- [2. Add an identity edge](#add-an-identity-edge) +- [3. Implement per-artifact stages and validation](#stages-and-validation) +- [4. Update current-version consumers](#current-version-consumers) +- [5. Create snapshot successors](#snapshot-successors) +- [6. Validate the integrated result](#validate) +- [Dev Note](#dev-note) + + +## 1. Choose the version and release base + +Bump the format for a structural change to headers, event envelopes, core event semantics, or surface reconstruction. Ordinary event additions do not require a bump; follow the [versioning rule](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Distinguish the Session format integer from package release versions, SQLite schema versions, projection-unit versions, and protocol-wrapper versions. + +Use a shared `release/*` integration base, such as `release/session-log-v3`. The base change adds the V3 writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child's branch. Each child adds its own structural transformation, validators, consumers, and tests to the same `session-format-v2-to-v3` package. Do not introduce V4 or V5 just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it. + +Released codecs and migration semantics remain frozen. Do not amend V0→V1 or V1→V2 to implement a new V3 feature. Before V3 ships, its single incoming edge can incorporate the coordinated changes; after release, a structural change needs the next adjacent edge. + +Use disposable, isolated Harness homes for unreleased integration testing. An interim V3 file already has the current version, so a later edit to V2→V3 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home. + + +## 2. Add an identity edge + +Follow the package checklist to create a library, not a mounted plugin. An identity body conversion is only an initial wiring scaffold; the integrated [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) defines the actual transformations and preservation rules. Do not treat its structural conversion as an identity edge. + +Declare `dsh.sessionFormatMigration` in the package manifest with `from: 2`, `to: 3`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse `releasedV2SessionFormatCodec` from the preceding edge and depend on that package; do not copy or redefine the released V2 codec. Export the V3 codec and validators from the new package. Add the new edge as a direct dependency of the catalog and add the workspace's TypeScript paths and project references. + +Set `SESSION_FORMAT_VERSION` in [core Session types](../../packages/core/session/src/types.ts) to 3, then generate the catalog: + +```sh +pnpm run gen-session-format-catalog +``` + +The [generator](../../scripts/gen-session-format-catalog.ts) requires exactly one adjacent package for every step from zero to the writer version, matching directory/package names, matching adjacent codec exports, and declared dependencies. It rejects gaps, duplicate or extra edges, unknown metadata members, and a catalog that does not share Session through peer plus development dependencies. Fix the declarations rather than hand-editing `generated.ts`. The catalog is build-static; plugin mounting must not determine historical readability. + + +## 3. Implement per-artifact stages and validation + +Use the [Stage interfaces](../../packages/session/session-format/src/types.ts), not a whole-artifact array-to-array migrator. An immutable `SessionFormatMigration` declaration supplies `migrateHeader`, `validateTargetHeader`, and `createStage`. Every call to `createStage` creates independent state for one source artifact. Keep counters, pending events, and reference maps there; never share a mutable stage across Sessions. + +Implement `transformEvent(event, context)`, `transformRun(run, context)`, and `finish(context)`. Emit synchronously through `context.emitEvent` or `context.emitRun`; a call can produce zero, one, or many outputs. Let a stage consume codec-owned compact runs directly, or iterate `run.expand()` without materializing an intermediate array. The caller owns scheduling, and the chain finishes upstream stages before downstream stages. + +Treat the inherited cut as a logical event count, not a physical row count. Expose `headerInheritedEventCount` only when it is known before EOF; `finish` returns the exact target cut. A preceding cardinality-changing edge can make that count unavailable at construction. Derive it from validated seed markers when required, and test V0→V1→V2→V3 and V1→V2→V3 with seeded Sessions, not just direct V2 input. Never substitute zero for an unknown cut. + +Define each edge's event admission and transformation rules explicitly; the [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) owns this edge's policy. The [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) owns the preceding edge's policy. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation. + +Prove strict restoration through `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })`, feeding rows in order and calling `finish()`. This exercises physical decoding, the complete chain, and installed current Session validation. Production's recoverable/transformed policy is not a replacement for strict fixture and publication verification. Preserve documented historical validation exceptions rather than claiming stricter source validation than the edge actually performs. + + +## 4. Update current-version consumers + +Trace each current-version consumer, including Session creation/restoration, JSONL filename selection and publication, the catalog's current encoder/restorer, projection-cache generation identity, replay and snapshot normalization, and TypeScript/Python SDK recordings. Use the writer constant where a value means current; keep literal historical versions in released codecs and historical fixtures. Update current documentation and generated references through their owners. + +Do not bump unrelated versions automatically. A request wrapper's `sessionFormatVersion` identifies its embedded Session generation; its outer schema version has its own meaning. Projection-unit state versions likewise do not replace the cache's Session-generation identity. + +Verify both read and write paths. Header-only listing must not read bodies or publish. Historical read open may return the migrated in-memory artifact without writing; write open must verify and publish only the final current successor before append. The source path, bytes, and inode stay unchanged. A newer or invalid selected generation must not cause fallback to a predecessor. The [preparation decision](../../.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md) owns publication timing. + + +## 5. Create snapshot successors + +Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). Select the owning scenario, not an adapter that only references it. For each role, keep the historical file and generate the current successor: `session.v3.jsonl` for the parent and `session.1.v3.jsonl`, `session.2.v3.jsonl`, and so on for children. Never rename `session.v2.jsonl` to V3 or change only its header. + +For unchanged replay input, use keyless refresh on the owner, then replay without write-back. This concrete SDK example uses `text-turn`; select the actual affected owner for a feature: + +```sh +pnpm run test:snapshot:refresh snapshots/sdk/sdk.snapshot.ts -t text-turn +pnpm run test:snapshot snapshots/sdk/sdk.snapshot.ts -t text-turn +``` + +Review the new generation, request sidecars, and protocol output together. Verify every predecessor remains byte-identical and that parent/child roles remain contiguous. Selection uses the numerically highest generation, so update shared references to the owner's selected parent. Do not use the packed-layout migrator as a version upgrader. If the model transcript must change, the scenario owner uses live recording under the [testing policy](../testing.md), with its required provider key. + +Keep deliberate historical cases explicit through `snapshot.yml`'s `sessionFormat.version` and supported `coverage` names; record and refresh leave their Session fixtures untouched. Update the [corpus policy](../../scripts/session-snapshot-corpus-policy.ts) for the current generation while retaining focused direct-edge, multi-hop, packed-row, retry/failure, and shipped-profile coverage. Check the corpus and both SDK projections; do not mass-refresh unrelated scenarios merely to silence a validation failure. + + +## 6. Validate the integrated result + +Run from the repository root. These focused commands check catalog declarations, Stage composition, the new edge, and generation selection: + +```sh +pnpm run verify-session-format-catalog +pnpm exec vitest run scripts/gen-session-format-catalog.spec.ts packages/session/session-format/tests packages/session/session-format-v2-to-v3/tests packages/session/session-format-catalog/tests +pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts +``` + +Add the changed JSONL, replay, projection, and SDK tests selected by the actual diff, plus the built publication-Worker smoke when that path changes. Require successful strict migration, identity preservation for the skeleton, malformed and unknown-required-event refusal, deterministic repeated restores, independent concurrent stage state, seeded multi-hop cuts, unchanged predecessors, and no fallback. Report exact commands and failures, not an inferred full-suite result. + +Update the [owning Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) rather than adding a redundant decision record. Audit related active notes for supersession; retain independent rationale and leave archived notes frozen. Update bilingual prose together, re-record each changed pair with the repository tool, then run documentation checks: + +```sh +pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md +pnpm run test:docs +pnpm run doc-sync +pnpm run lint +git diff --check +``` + + +## Dev Note + +None. diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md new file mode 100644 index 0000000000..6bb93f5ff9 --- /dev/null +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -0,0 +1,109 @@ +# 实操手册:添加 Session 日志格式版本 + +[English](adding-a-session-format-version.md) | 中文 + +## 概述 + +本教程介绍如何添加结构性的 Session 日志版本,同时不改写已发布数据。示例通过单条 V2→V3 迁移边添加 V3,再让独立评审的变更扩展这条尚未发布的迁移边。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。 + +## 目录 + +- [1. 选择版本与发布基线](#choose-the-version) +- [2. 添加恒等迁移边](#add-an-identity-edge) +- [3. 实现每份产物独占的 Stage 与校验](#stages-and-validation) +- [4. 更新当前版本消费方](#current-version-consumers) +- [5. 创建快照后继代际](#snapshot-successors) +- [6. 验证集成结果](#validate) +- [开发备注](#dev-note) + + +## 1. 选择版本与发布基线 + +当 header、事件信封、核心事件语义或表面重建发生结构性变更时,提升格式版本。普通事件新增不需要提升版本;遵循[版本规则](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。区分 Session 格式整数与包发布版本、SQLite schema 版本、投影单元版本及协议包装层版本。 + +使用共享的 `release/*` 集成基线,例如 `release/session-log-v3`。基线变更添加 V3 写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个 `session-format-v2-to-v3` 包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而引入 V4 或 V5。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 + +已发布 codec 和迁移语义保持冻结。不要通过修改 V0→V1 或 V1→V2 来实现新的 V3 功能。在 V3 发布前,其唯一入边可以纳入这些协同变更;发布后,结构性变更需要下一条相邻迁移边。 + +未发布版本的集成测试应使用可丢弃、相互隔离的 Harness home。中间版本产生的 V3 文件已经标为当前版本,因此后续对 V2→V3 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。 + + +## 2. 添加恒等迁移边 + +按照包检查清单创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架;集成后的 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)定义实际转换与保留规则。不要将其结构转换视为恒等迁移边。 + +在包 manifest(元数据清单)中声明 `dsh.sessionFormatMigration`,包含 `from: 2`、`to: 3`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用前一条迁移边的 `releasedV2SessionFormatCodec`,并依赖该包;不要复制或重新定义已发布 V2 codec。从新包导出 V3 codec 和校验器。将新迁移边加入 catalog 的直接依赖,并添加工作区的 TypeScript 路径与项目引用。 + +将[核心 Session 类型](../../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 设为 3,然后生成 catalog: + +```sh +pnpm run gen-session-format-catalog +``` + +[生成器](../../scripts/gen-session-format-catalog.ts)要求从零到写入器版本的每一步恰好有一个相邻迁移包,目录与包名匹配、相邻 codec 导出匹配,并声明所需依赖。它拒绝缺口、重复或多余的迁移边、未知元数据成员,以及未通过对等依赖(peer dependency)加开发依赖共享 Session 的 catalog。请修复声明,而非手改 `generated.ts`。Catalog 在构建时静态确定;插件挂载不得决定历史数据是否可读。 + + +## 3. 实现每份产物独占的 Stage 与校验 + +使用 [Stage 接口](../../packages/session/session-format/src/types.ts),不要使用整份产物的数组到数组迁移器。不可变的 `SessionFormatMigration` 声明提供 `migrateHeader`、`validateTargetHeader` 和 `createStage`。每次调用 `createStage` 都为一份源产物创建独立状态。计数器、待处理事件和引用映射归该状态所有;不同 Session 之间绝不共享可变 Stage。 + +实现 `transformEvent(event, context)`、`transformRun(run, context)` 和 `finish(context)`。通过 `context.emitEvent` 或 `context.emitRun` 同步输出;一次调用可以产生零个、一个或多个输出。让 Stage 直接消费 codec 所有的紧凑 run,或者迭代 `run.expand()`,而不物化中间数组。调用方负责调度,迁移链先结束上游 Stage,再结束下游 Stage。 + +继承截点是逻辑事件数量,不是物理行数。只有在 EOF 前已知时才公开 `headerInheritedEventCount`;`finish` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并用有种子的 Session 测试 V0→V1→V2→V3 和 V1→V2→V3,而非仅测试直接 V2 输入。绝不以零替代未知截点。 + +显式定义每条迁移边的事件准入与变换规则;[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)负责本迁移边的策略。[Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)负责前代迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。 + +通过 `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })` 验证严格恢复,按顺序传入各行并调用 `finish()`。这会执行物理解码、完整迁移链与已安装当前 Session 校验。生产环境的 recoverable/transformed 策略不能替代 fixture(测试前置数据)和发布验证所需的严格校验。保留已记录的历史校验例外,不要宣称源校验比迁移边实际执行的更严格。 + + +## 4. 更新当前版本消费方 + +追踪每个当前版本消费方,包括 Session 创建与恢复、JSONL 文件名选择与发布、catalog 的当前编码器与恢复器、投影缓存的代际身份、回放与快照归一化,以及 TypeScript/Python SDK 录制。当值表示当前版本时使用写入器常量;在已发布 codec 和历史 fixture 中保留字面历史版本。通过各自所有者更新当前文档与生成参考。 + +不要自动提升无关版本。请求包装层的 `sessionFormatVersion` 标识嵌入的 Session 代际;外层 schema 版本有自己的含义。投影单元状态版本同样不能替代缓存的 Session 代际身份。 + +验证读取与写入两条路径。仅 header 的列表操作不得读取正文或发布。历史读取打开可以直接返回迁移后的内存产物而不写入;写入打开必须先校验并发布唯一的最终当前后继代际,再允许追加。源路径、字节与 inode 保持不变。所选代际高于当前版本或无效时,不得回退到前代。[准备阶段决策](../../.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md)负责发布时序。 + + +## 5. 创建快照后继代际 + +阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。选择拥有数据的场景,而非仅引用它的适配器。为每个角色保留历史文件,并生成当前后继文件:父角色使用 `session.v3.jsonl`,子角色依次使用 `session.1.v3.jsonl`、`session.2.v3.jsonl` 等。绝不将 `session.v2.jsonl` 重命名为 V3,或仅修改其 header。 + +如果回放输入不变,在所有者上执行无密钥 refresh,再执行不写回的 replay。这个具体 SDK 示例使用 `text-turn`;功能变更应选择实际受影响的所有者: + +```sh +pnpm run test:snapshot:refresh snapshots/sdk/sdk.snapshot.ts -t text-turn +pnpm run test:snapshot snapshots/sdk/sdk.snapshot.ts -t text-turn +``` + +一起审查新代际、请求伴随文件与协议输出。验证每个前代的字节保持相同,且父子角色连续。选择规则采用数值最高的代际,因此应将共享引用更新为所有者选中的父代际。不要把 packed 布局迁移器当作版本升级器。如果模型 transcript(文本记录)必须变化,由场景所有者按照[测试策略](../testing.zh.md)使用所需提供方密钥进行实时录制。 + +通过 `snapshot.yml` 的 `sessionFormat.version` 与受支持的 `coverage` 名称显式保留历史案例;record 和 refresh 不改动这些 Session fixture。更新[语料策略](../../scripts/session-snapshot-corpus-policy.ts)以采用当前代际,同时保留聚焦的直接迁移边、多跳、packed row、重试/失败及交付 profile 覆盖。检查语料和两个 SDK 投影;不要仅为消除校验失败而批量 refresh 无关场景。 + + +## 6. 验证集成结果 + +从仓库根目录运行。以下聚焦命令检查 catalog 声明、Stage 组合、新迁移边与代际选择: + +```sh +pnpm run verify-session-format-catalog +pnpm exec vitest run scripts/gen-session-format-catalog.spec.ts packages/session/session-format/tests packages/session/session-format-v2-to-v3/tests packages/session/session-format-catalog/tests +pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts +``` + +根据实际 diff 添加受影响的 JSONL、回放、投影与 SDK 测试;发布 Worker 路径变化时还需构建产物冒烟测试。要求严格迁移成功、骨架保持恒等、拒绝格式错误与未知必需事件、重复恢复确定、并发 Stage 状态独立、有种子的多跳截点正确、前代不变且无回退。报告确切命令与失败,不要推断整个测试套件的结果。 + +更新[所属 Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md),而非添加重复决策记录。审计相关活跃记录的取代关系;保留独立理由,并保持归档记录冻结。一起更新双语正文,通过仓库工具重新记录每个变更的配对,然后运行文档检查: + +```sh +pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md +pnpm run test:docs +pnpm run doc-sync +pnpm run lint +git diff --check +``` + + +## 开发备注 + +无。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index d94e0a3d90..1af6f9ea46 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: b7f0f7939797d7c9929a87427eb624b7cfcc7a87 -extension-cookbook.zh.md: 6813a41baad4488123a2ad92672fdc275016d98b +extension-cookbook.md: a92b3f1ff5dbbdde717aea812b65a729f10d44c4 +extension-cookbook.zh.md: 23d21084c8e9ac375a482e151edd4f89f1f0c7ea diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index b7f0f79397..a92b3f1ff5 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -2,7 +2,7 @@ English | [中文](extension-cookbook.zh.md) -Reference patterns for harness extensions. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), and [LLM adapter guide](adding-an-llm-adapter.md); the [architecture](../architecture.md) owns the system and extension-point map. +Reference patterns for harness extensions. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), [LLM adapter guide](adding-an-llm-adapter.md), and [Session format version tutorial](adding-a-session-format-version.md); the [architecture](../architecture.md) owns the system and extension-point map. ## A tool plugin diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 6813a41baa..23d21084c8 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -2,7 +2,7 @@ [English](extension-cookbook.md) | 中文 -harness 扩展的参考模式。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.zh.md)、[第一个工具教程](../user/develop/basic/tool.zh.md)、[工具参考](adding-a-tool.zh.md)和 [LLM(大语言模型)适配器指南](adding-an-llm-adapter.zh.md);系统与扩展点映射由[架构文档](../architecture.zh.md)负责。 +harness 扩展的参考模式。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.zh.md)、[第一个工具教程](../user/develop/basic/tool.zh.md)、[工具参考](adding-a-tool.zh.md)、[LLM(大语言模型)适配器指南](adding-an-llm-adapter.zh.md)和 [Session 格式版本教程](adding-a-session-format-version.zh.md);系统与扩展点映射由[架构文档](../architecture.zh.md)负责。 ## 工具插件 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index dcb4c44e45..b1f10816f3 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 99811d1dac8b51e96544eea95b1d67379cc15f14 -event-producer-consumer.zh.md: 68ea7460eeb2192b6d8d5195fec1254c457e7c16 +event-producer-consumer.md: 449b7d8f0fb55515e7f1f028e151ce1b26862e92 +event-producer-consumer.zh.md: 8120df6de6776bce8841f75311294583417532c2 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 99811d1dac..449b7d8f0f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,24 +9,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:403`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:571`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:584`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -46,7 +46,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | @@ -55,10 +55,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 68ea7460ee..8120df6de6 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,24 +11,24 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:403`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:571`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:584`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -48,7 +48,7 @@ | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | @@ -68,7 +68,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index fa254b9d2a..cf4f2a6735 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: f0ebb388318ed4a0aa8896a8040a5539f5477037 -module-graph.zh.md: 0cc8ff7b9a7601102f8d708b8ebb2d17c7bb0612 +module-graph.md: e498227db7cc482beb2dd69aab147baec8eead22 +module-graph.zh.md: c3956f8b466f791b2b90aee12bedba7f95ff7cb9 diff --git a/docs/module-graph.md b/docs/module-graph.md index f0ebb38831..e498227db7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_chunked_list["chunked-list"] pkg_deque["deque"] pkg_home_paths["home-paths"] pkg_http_proxy["http-proxy"] @@ -55,6 +56,7 @@ flowchart TD pkg_fs_sandbox["fs-sandbox"] pkg_tool_fs["tool-fs"] pkg_tool_fs_search["tool-fs-search"] + pkg_tool_present["tool-present"] pkg_tool_str_replace_editor["tool-str-replace-editor"] end subgraph group_skill["packages/skill"] @@ -171,9 +173,9 @@ flowchart TD pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] pkg_client_ui_sidebar["client-ui-sidebar"] + pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] - pkg_client_ui_sidebar_textpreview["client-ui-sidebar-textpreview"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -298,6 +300,7 @@ flowchart TD pkg_session_format_catalog["session-format-catalog"] pkg_session_format_v0_to_v1["session-format-v0-to-v1"] pkg_session_format_v1_to_v2["session-format-v1-to-v2"] + pkg_session_format_v2_to_v3["session-format-v2-to-v3"] pkg_session_log_deepseek["session-log-deepseek"] pkg_session_persistence["session-persistence"] pkg_session_persistence_jsonl["session-persistence-jsonl"] @@ -737,6 +740,12 @@ flowchart TD pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools + pkg_tool_present --> pkg_agent + pkg_tool_present --> pkg_fs + pkg_tool_present --> pkg_llm + pkg_tool_present --> pkg_session + pkg_tool_present --> pkg_session_projection + pkg_tool_present --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy @@ -1171,6 +1180,7 @@ flowchart TD | --- | --- | --- | | [`atomic-write`](../packages/util/atomic-write) | `util` | — | | [`brand`](../packages/util/brand) | `util` | — | +| [`chunked-list`](../packages/util/chunked-list) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | | [`http-proxy`](../packages/util/http-proxy) | `util` | — | @@ -1229,9 +1239,9 @@ flowchart TD | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | +| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | -| [`client-ui-sidebar-textpreview`](../packages/client/ui-sidebar-textpreview) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | @@ -1258,6 +1268,7 @@ flowchart TD | [`session-format`](../packages/session/session-format) | `session` | — | | [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — | | [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — | +| [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — | | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | @@ -1366,6 +1377,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 0cc8ff7b9a..c3956f8b46 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -12,6 +12,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_chunked_list["chunked-list"] pkg_deque["deque"] pkg_home_paths["home-paths"] pkg_http_proxy["http-proxy"] @@ -57,6 +58,7 @@ flowchart TD pkg_fs_sandbox["fs-sandbox"] pkg_tool_fs["tool-fs"] pkg_tool_fs_search["tool-fs-search"] + pkg_tool_present["tool-present"] pkg_tool_str_replace_editor["tool-str-replace-editor"] end subgraph group_skill["packages/skill"] @@ -173,9 +175,9 @@ flowchart TD pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] pkg_client_ui_sidebar["client-ui-sidebar"] + pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] - pkg_client_ui_sidebar_textpreview["client-ui-sidebar-textpreview"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -300,6 +302,7 @@ flowchart TD pkg_session_format_catalog["session-format-catalog"] pkg_session_format_v0_to_v1["session-format-v0-to-v1"] pkg_session_format_v1_to_v2["session-format-v1-to-v2"] + pkg_session_format_v2_to_v3["session-format-v2-to-v3"] pkg_session_log_deepseek["session-log-deepseek"] pkg_session_persistence["session-persistence"] pkg_session_persistence_jsonl["session-persistence-jsonl"] @@ -739,6 +742,12 @@ flowchart TD pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools + pkg_tool_present --> pkg_agent + pkg_tool_present --> pkg_fs + pkg_tool_present --> pkg_llm + pkg_tool_present --> pkg_session + pkg_tool_present --> pkg_session_projection + pkg_tool_present --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy @@ -1173,6 +1182,7 @@ flowchart TD | --- | --- | --- | | [`atomic-write`](../packages/util/atomic-write) | `util` | — | | [`brand`](../packages/util/brand) | `util` | — | +| [`chunked-list`](../packages/util/chunked-list) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | | [`http-proxy`](../packages/util/http-proxy) | `util` | — | @@ -1231,9 +1241,9 @@ flowchart TD | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | +| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | -| [`client-ui-sidebar-textpreview`](../packages/client/ui-sidebar-textpreview) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | @@ -1260,6 +1270,7 @@ flowchart TD | [`session-format`](../packages/session/session-format) | `session` | — | | [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — | | [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — | +| [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — | | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | @@ -1368,6 +1379,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 6775871bcd..8049e2d430 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 8d6f66dc949b6d41530eb387534689dc66f1e99f -persistence-catalog.zh.md: 06d07ceab984ad20ee6637227d54fafbbb58bdd4 +persistence-catalog.md: a9e1221a564a4ad1af1353278f1215bb33fb9c4f +persistence-catalog.zh.md: 2b74b03b4b783f848385e66e15c40d73f50a788b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8d6f66dc94..a9e1221a56 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -18,10 +18,11 @@ export type SessionEventType = keyof SessionEventMap /** * The subset of {@link SessionEventType} values whose events produce LLM * messages and are eligible to appear on the ordered surface. Only these - * event types may carry {@link SurfaceOp}; user and tool events may also cite + * event types may carry {@link SurfaceOp}; system, user, and tool events may also cite * earlier sources through {@link SessionEvent.sourceEventSeqs}. */ export type SurfaceEventType = + | 'system/message' | 'user/message' | 'assistant/message' | 'tool/result' @@ -32,16 +33,16 @@ export type SurfaceEventType = * * - `'append'`: added to the tail — normal path for user/assistant/tool * messages. - * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` - * (inclusive) through `end` (inclusive) with this node. Both must exist as - * surface nodes in the current surface. `start === end` replaces a single + * - `{ op: 'replace', startSeq, endSeq }`: replaces surface nodes from `startSeq` + * (inclusive) through `endSeq` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `startSeq === endSeq` replaces a single * node. The node's {@link SessionEvent.sourceEventSeqs} must include every * shadowed surface node. Used by compaction; any surface-replacing producer * may use it. */ export type SurfaceOp = | 'append' - | { op: 'replace'; start: SessionSeq; end: SessionSeq } + | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq } /** * One immutable entry in the session log. @@ -50,7 +51,7 @@ export type SurfaceOp = * unions), so `switch (event.type)` narrows `event.data` without casts. * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: - * they only exist on {@link SurfaceEventType} variants (`user/message`, + * they only exist on {@link SurfaceEventType} variants (`system/message`, `user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, attempts, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` @@ -75,20 +76,14 @@ export type SessionEvent = { * inconvenience) rather than silently resuming a gutted session. */ ignorable?: true - } & (K extends SurfaceEventType ? { - /** - * Seq numbers of earlier events that this event cites as sources, such as - * the surface nodes shadowed by a compaction replacement. A v2 - * `assistant/message` embeds its provider stream and cannot carry this field. - */ - sourceEventSeqs?: SessionSeq[] - /** How this event entered the surface; absent for non-surface events. */ - surfaceOp?: SurfaceOp - } : object) + } & (K extends SurfaceEventType ? SurfaceIntent : { + surfaceOp?: never + sourceEventSeqs?: never + }) }[T] ``` -Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:393`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:422`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:453`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) ## Events @@ -215,7 +210,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) @@ -245,7 +240,7 @@ Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) ### `command/*` @@ -401,6 +396,21 @@ Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streami Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts) +### `deliverables/*` + + + +#### `deliverables/presented` — log-only + +```ts persistence-catalog +/** Declared workspace files from a successful final present result, including nested calls. */ +'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } +``` + +Types: [ToolCallId](subsystems/core.md) + +Source: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts) + ### `feedback/*` @@ -587,13 +597,15 @@ Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/s ```ts persistence-catalog /** - * Route metadata for the next request, logged only when the route or capacity - * changes. It does not participate in request reconstruction or header equality. + * Route metadata for the next request, logged only when the route, capacity, + * or system prompt update mode changes. It does not participate in request + * reconstruction or header equality. Prompt admission uses the bound prepared + * call's capability, not this snapshot from an earlier request. */ 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:358`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) @@ -612,7 +624,7 @@ Source: [`packages/core/session/src/types.ts:358`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:348`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -687,7 +699,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch 'session/end-seed': { inherited?: true } ``` -Source: [`packages/core/session/src/types.ts:381`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts) @@ -736,7 +748,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:45`](../packages/sessi } ``` -Source: [`packages/session/session-log-deepseek/src/types.ts:59`](../packages/session/session-log-deepseek/src/types.ts) +Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/session/session-log-deepseek/src/types.ts) ### `step/*` @@ -749,7 +761,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:59`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) @@ -760,10 +772,24 @@ Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) ### `subagent/*` + + +#### `subagent/catalog` — log-only + +```ts persistence-catalog +/** + * A direct child's complete discovery fact. + * @param data - versioned parent-owned catalog entry. + */ +'subagent/catalog': SubagentCatalogEvent +``` + +Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) + #### `subagent/descriptor` — log-only @@ -800,6 +826,30 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +### `system/*` + + + +#### `system/message` — surface + +```ts persistence-catalog +/** + * The rendered system prompt on the model-visible surface. The loop appends + * the first one as surface node 0 before the step's first `user/message`. + * A prepared in-history route can append nonempty changes in a continuing + * series. An incapable route or new series normalizes text to the first system + * node. Normalization empties nonempty later nodes, then rewrites the head if + * needed, through logged per-node replacements. An empty rendering always + * clears all active system nodes, leaving no older instructions model-visible. + * Empty later nodes are dormant and project to no message; an empty head with + * no active later node records "no system prompt". Restored nonempty text follows + * the same route and series rule; empty nodes never restore older text. + */ +'system/message': { turn: number; step: number; message: SystemMessage } +``` + +Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) + ### `team/*` @@ -891,16 +941,16 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) - + -#### `tool/code-dispatch` — log-only +#### `tool/ptc-dispatch` — log-only ```ts persistence-catalog /** * One bridged sub-dispatch SETTLING: the pairing ids (matching the - * `tool/code-dispatch-start` with the same `subCallId`), the tool `name` + * `tool/ptc-dispatch-start` with the same `subCallId`), the tool `name` * with the same JSON-normalized `arguments`, and the sub-call's complete * model-facing outcome in `tool/result`'s own vocabulary * (`content` + `isError`), so UIs render a sub-call through the exact @@ -913,30 +963,30 @@ Source: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/ * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': PtcDispatchEventData +'tool/ptc-dispatch': PtcDispatchEventData ``` Source: [`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) - + -#### `tool/code-dispatch-start` — log-only +#### `tool/ptc-dispatch-start` — log-only ```ts persistence-catalog /** * One sub-dispatch STARTING inside a `run_code` program: the parent - * `run_code` call id, the deterministic sub-call id (`:code:`, - * numbered in submission order), and the tool `name` with its + * `run_code` call id, the opaque sub-call id (new calls use + * `:ptc:`, numbered in submission order), and the tool `name` with its * JSON-normalized `arguments` — the exact value dispatched, normalized * BEFORE dispatch, so this append can never fail on payload shape. * Appended when the scheduler actually starts the call (not at * submission), so a start means the tool body pipeline was entered; a * call abandoned in the queue logs nothing. Log-only: `deriveMessages()` * ignores it; UIs use it for live per-sub-call running state and pair it - * with `tool/code-dispatch` by `subCallId` (timing = the two events' + * with `tool/ptc-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': PtcDispatchStartEventData +'tool/ptc-dispatch-start': PtcDispatchStartEventData ``` Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) @@ -961,12 +1011,13 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types turn: number step: number message: ToolResultMessage + /** Optional failure identity; allowed only when the tool-result block has `isError: true`. */ error?: { name: string; code: string } meta?: JsonValue } ``` -Source: [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:353`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1046,7 +1097,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) @@ -1062,7 +1113,7 @@ Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) ### `user/*` @@ -1081,7 +1132,7 @@ Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 06d07ceab9..2b74b03b4b 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -20,10 +20,11 @@ export type SessionEventType = keyof SessionEventMap /** * The subset of {@link SessionEventType} values whose events produce LLM * messages and are eligible to appear on the ordered surface. Only these - * event types may carry {@link SurfaceOp}; user and tool events may also cite + * event types may carry {@link SurfaceOp}; system, user, and tool events may also cite * earlier sources through {@link SessionEvent.sourceEventSeqs}. */ export type SurfaceEventType = + | 'system/message' | 'user/message' | 'assistant/message' | 'tool/result' @@ -34,16 +35,16 @@ export type SurfaceEventType = * * - `'append'`: added to the tail — normal path for user/assistant/tool * messages. - * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` - * (inclusive) through `end` (inclusive) with this node. Both must exist as - * surface nodes in the current surface. `start === end` replaces a single + * - `{ op: 'replace', startSeq, endSeq }`: replaces surface nodes from `startSeq` + * (inclusive) through `endSeq` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `startSeq === endSeq` replaces a single * node. The node's {@link SessionEvent.sourceEventSeqs} must include every * shadowed surface node. Used by compaction; any surface-replacing producer * may use it. */ export type SurfaceOp = | 'append' - | { op: 'replace'; start: SessionSeq; end: SessionSeq } + | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq } /** * One immutable entry in the session log. @@ -52,7 +53,7 @@ export type SurfaceOp = * unions), so `switch (event.type)` narrows `event.data` without casts. * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: - * they only exist on {@link SurfaceEventType} variants (`user/message`, + * they only exist on {@link SurfaceEventType} variants (`system/message`, `user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, attempts, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` @@ -77,20 +78,14 @@ export type SessionEvent = { * inconvenience) rather than silently resuming a gutted session. */ ignorable?: true - } & (K extends SurfaceEventType ? { - /** - * Seq numbers of earlier events that this event cites as sources, such as - * the surface nodes shadowed by a compaction replacement. A v2 - * `assistant/message` embeds its provider stream and cannot carry this field. - */ - sourceEventSeqs?: SessionSeq[] - /** How this event entered the surface; absent for non-surface events. */ - surfaceOp?: SurfaceOp - } : object) + } & (K extends SurfaceEventType ? SurfaceIntent : { + surfaceOp?: never + sourceEventSeqs?: never + }) }[T] ``` -来源:[`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:393`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:422`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:453`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) ## 事件 @@ -200,7 +195,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/user-approval/src/index.ts:32`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:33`](../packages/interaction/user-approval/src/index.ts) ### `assistant/*` @@ -217,7 +212,7 @@ export type SessionEvent = { 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -来源:[`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) @@ -247,7 +242,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) ### `command/*` @@ -403,6 +398,21 @@ export type SessionEvent = { 来源:[`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts) +### `deliverables/*` + + + +#### `deliverables/presented` — 仅日志 + +```ts persistence-catalog +/** Declared workspace files from a successful final present result, including nested calls. */ +'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } +``` + +类型: [ToolCallId](subsystems/core.zh.md) + +来源: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts) + ### `feedback/*` @@ -414,7 +424,7 @@ export type SessionEvent = { 'feedback/message-delete': MessageFeedbackDelete ``` -来源: [`packages/feedback/message-feedback/src/types.ts:55`](../packages/feedback/message-feedback/src/types.ts) +来源:[`packages/feedback/message-feedback/src/types.ts:55`](../packages/feedback/message-feedback/src/types.ts) @@ -425,7 +435,7 @@ export type SessionEvent = { 'feedback/message-put': MessageFeedbackPut ``` -来源: [`packages/feedback/message-feedback/src/types.ts:53`](../packages/feedback/message-feedback/src/types.ts) +来源:[`packages/feedback/message-feedback/src/types.ts:53`](../packages/feedback/message-feedback/src/types.ts) @@ -544,7 +554,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -589,13 +599,15 @@ export type SessionEvent = { ```ts persistence-catalog /** - * Route metadata for the next request, logged only when the route or capacity - * changes. It does not participate in request reconstruction or header equality. + * Route metadata for the next request, logged only when the route, capacity, + * or system prompt update mode changes. It does not participate in request + * reconstruction or header equality. Prompt admission uses the bound prepared + * call's capability, not this snapshot from an earlier request. */ 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:358`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) @@ -614,7 +626,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:348`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -689,7 +701,7 @@ export type SessionEvent = { 'session/end-seed': { inherited?: true } ``` -来源:[`packages/core/session/src/types.ts:381`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts) @@ -738,7 +750,7 @@ export type SessionEvent = { } ``` -来源:[`packages/session/session-log-deepseek/src/types.ts:59`](../packages/session/session-log-deepseek/src/types.ts) +来源:[`packages/session/session-log-deepseek/src/types.ts:81`](../packages/session/session-log-deepseek/src/types.ts) ### `step/*` @@ -751,7 +763,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) @@ -762,10 +774,24 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) ### `subagent/*` + + +#### `subagent/catalog` — log-only + +```ts persistence-catalog +/** + * A direct child's complete discovery fact. + * @param data - versioned parent-owned catalog entry. + */ +'subagent/catalog': SubagentCatalogEvent +``` + +来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) + #### `subagent/descriptor` — log-only @@ -802,6 +828,30 @@ export type SessionEvent = { 来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +### `system/*` + + + +#### `system/message` — surface + +```ts persistence-catalog +/** + * The rendered system prompt on the model-visible surface. The loop appends + * the first one as surface node 0 before the step's first `user/message`. + * A prepared in-history route can append nonempty changes in a continuing + * series. An incapable route or new series normalizes text to the first system + * node. Normalization empties nonempty later nodes, then rewrites the head if + * needed, through logged per-node replacements. An empty rendering always + * clears all active system nodes, leaving no older instructions model-visible. + * Empty later nodes are dormant and project to no message; an empty head with + * no active later node records "no system prompt". Restored nonempty text follows + * the same route and series rule; empty nodes never restore older text. + */ +'system/message': { turn: number; step: number; message: SystemMessage } +``` + +来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) + ### `team/*` @@ -815,7 +865,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:204`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts) @@ -833,7 +883,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts) @@ -846,7 +896,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:208`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts) @@ -859,7 +909,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:206`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -893,16 +943,16 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) - + -#### `tool/code-dispatch` — log-only +#### `tool/ptc-dispatch` — log-only ```ts persistence-catalog /** * One bridged sub-dispatch SETTLING: the pairing ids (matching the - * `tool/code-dispatch-start` with the same `subCallId`), the tool `name` + * `tool/ptc-dispatch-start` with the same `subCallId`), the tool `name` * with the same JSON-normalized `arguments`, and the sub-call's complete * model-facing outcome in `tool/result`'s own vocabulary * (`content` + `isError`), so UIs render a sub-call through the exact @@ -915,30 +965,30 @@ export type SessionEvent = { * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': PtcDispatchEventData +'tool/ptc-dispatch': PtcDispatchEventData ``` 来源:[`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) - + -#### `tool/code-dispatch-start` — log-only +#### `tool/ptc-dispatch-start` — log-only ```ts persistence-catalog /** * One sub-dispatch STARTING inside a `run_code` program: the parent - * `run_code` call id, the deterministic sub-call id (`:code:`, - * numbered in submission order), and the tool `name` with its + * `run_code` call id, the opaque sub-call id (new calls use + * `:ptc:`, numbered in submission order), and the tool `name` with its * JSON-normalized `arguments` — the exact value dispatched, normalized * BEFORE dispatch, so this append can never fail on payload shape. * Appended when the scheduler actually starts the call (not at * submission), so a start means the tool body pipeline was entered; a * call abandoned in the queue logs nothing. Log-only: `deriveMessages()` * ignores it; UIs use it for live per-sub-call running state and pair it - * with `tool/code-dispatch` by `subCallId` (timing = the two events' + * with `tool/ptc-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': PtcDispatchStartEventData +'tool/ptc-dispatch-start': PtcDispatchStartEventData ``` 来源:[`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) @@ -963,12 +1013,13 @@ export type SessionEvent = { turn: number step: number message: ToolResultMessage + /** Optional failure identity; allowed only when the tool-result block has `isError: true`. */ error?: { name: string; code: string } meta?: JsonValue } ``` -来源:[`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:353`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1048,7 +1099,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) @@ -1064,7 +1115,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) ### `user/*` @@ -1083,7 +1134,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/subsystems/approval.i18n.yaml b/docs/subsystems/approval.i18n.yaml index 565f5010cc..9f505f4a5b 100644 --- a/docs/subsystems/approval.i18n.yaml +++ b/docs/subsystems/approval.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/approval.md -approval.md: 89130232b45da2982d0da40c8fad217b364758b2 -approval.zh.md: e8047cd5a8bf8dfa23b9e80ad97db00d0e25a061 +approval.md: 656325aa446321253817be3ffeaef658a7c975c4 +approval.zh.md: a22aec2ef23fdf4809a73e1bc8509eab2679742f diff --git a/docs/subsystems/approval.md b/docs/subsystems/approval.md index 89130232b4..656325aa44 100644 --- a/docs/subsystems/approval.md +++ b/docs/subsystems/approval.md @@ -46,7 +46,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' type ApprovalPolicy = 'ask' | 'never' ``` -Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot. The sourced `user/message` is the durable model-visible input; changing approval state appends a new full snapshot after retained history without rewriting the request header's system prompt. +Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot. The sourced `user/message` is the durable model-visible input; changing approval state appends a new full snapshot after retained history without touching the `system/message` nodes that hold the rendered system prompt. ## Approval request diff --git a/docs/subsystems/approval.zh.md b/docs/subsystems/approval.zh.md index e8047cd5a8..a22aec2ef2 100644 --- a/docs/subsystems/approval.zh.md +++ b/docs/subsystems/approval.zh.md @@ -46,7 +46,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' type ApprovalPolicy = 'ask' | 'never' ``` -两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。带来源的 `user/message` 是持久化且模型可见的输入;审批状态变化时,会在保留的历史后追加一份新的完整快照,而不改写请求头中的系统提示词。 +两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。带来源的 `user/message` 是持久化且模型可见的输入;审批状态变化时,会在保留的历史后追加一份新的完整快照,而不触碰承载渲染后系统提示词的 `system/message` 节点。 ## 审批请求 diff --git a/docs/subsystems/client-resources.i18n.yaml b/docs/subsystems/client-resources.i18n.yaml index e1267b8d56..9957789d79 100644 --- a/docs/subsystems/client-resources.i18n.yaml +++ b/docs/subsystems/client-resources.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/client-resources.md -client-resources.md: a1643e61779e5c19450c7e1688b0ce7fcfbee786 -client-resources.zh.md: 418c9aef91a58a85ff8102617074a38813c5f94b +client-resources.md: d89ba6af433cd41066835be85d348b8fdf2afef0 +client-resources.zh.md: 6bfbb3e4470f89c1a90853427ca6f3a684120b3e diff --git a/docs/subsystems/client-resources.md b/docs/subsystems/client-resources.md index a1643e6177..d89ba6af43 100644 --- a/docs/subsystems/client-resources.md +++ b/docs/subsystems/client-resources.md @@ -8,19 +8,19 @@ This page is the developer reference: how to write an address, how to register a ## Addresses -A resource address is a `dsh-resource:///…` URL. The host names the protocol and must be a key of `ResourceProtocolMap`; the path is the protocol's own, and its owner percent-encodes each segment. A protocol that needs a scope puts it in the path: the `file` protocol's addresses read `dsh-resource://file/session//` or `dsh-resource://file/absolute/`, built with `fileAddressFor(sessionId, cwd, path)` and read back with `parseFileAddress(address)` from [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.md). The model itself reads only the scheme and the host: `protocolOf(address)` returns the lower-cased host of a `dsh-resource://` URL and `undefined` for anything else. Addresses under any other scheme — the Sidebar's `sidebar://guide` — name no resource and read as `none`. +A resource address is a `dsh-resource:///…` URL. The host names the protocol and must be a key of `ResourceProtocolMap`; the path is the protocol's own, and its owner percent-encodes each segment. A protocol that needs a scope puts it in the path: the `file` protocol's addresses read `dsh-resource://file/session//`, where path is workspace-relative or absolute with its leading slashes preserved, built with `fileAddressFor(sessionId, cwd, path)` and read back with `parseFileAddress(address)` from [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.md). The model itself reads only the scheme and the host: `protocolOf(address)` returns the lower-cased host of a `dsh-resource://` URL and `undefined` for anything else. Addresses under any other scheme — the Sidebar's `sidebar://guide` — name no resource and read as `none`. | Address | Protocol key | Reads as | |---|---|---| | `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered | -| `dsh-resource://file/absolute/home/me/notes.md` | `file` | the metadata of that absolute path, read through the current session and confined to its workspace | +| `dsh-resource://file/absolute/home/me/notes.md` | `file` | parseable but fails with `workspace-file/unknown-workspace`: no authorizing Session, and neither current nor Tab Session is borrowed | | `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits | | `sidebar://guide` | — | `none`: a navigation address | | `/home/me/notes.md` | — | `none`: not a URL | ## Registering a provider -The owner of a protocol declares its value type on `ResourceProtocolMap` and registers one provider inside its own `ctx.effect`, so the protocol lives exactly as long as the plugin ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). `open(address, { signal })` returns a stream of `RemoteResult` frames — the current state first, then one frame per change — and must stop when `signal` aborts. A failure is an `ok: false` frame carrying a `RemoteFailure`; a throw inside the stream is a programming error and is not caught. `reload(address)` is optional and asks the open stream for a fresh frame. +The owner of a protocol declares its value type on `ResourceProtocolMap` and registers one provider inside its own `ctx.effect`, so the protocol lives exactly as long as the plugin ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). `open(address, { signal })` returns a stream of `RemoteResult` frames — the current state first, then one frame per change — and must stop when `signal` aborts. A failure is an `ok: false` frame carrying a `RemoteFailure`; a throw inside the stream is a programming error and is not caught. ```ts ignore-check import type { Context } from '@deepseek-ai/cordis' @@ -43,7 +43,6 @@ export function apply(ctx: Context): void { yield await ctx.remote.notes.read(id, signal) for await (const change of ctx.remote.notes.follow(id, signal)) yield change }, - reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) }, }), 'my-notes: note resource provider') } ``` @@ -61,21 +60,19 @@ Every slot component receives `useResource` in its props, whatever its scope ([S | `live` | The latest frame succeeded | the latest `ok` value | `undefined` | | `failed` | The latest frame reported a failure | the last `ok` value, kept | the frame's `RemoteFailure` | -`reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or the provider has no `reload`; the function is reference-stable per address, so a body may hold it. - ```tsx ignore-check import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-workspace-files/client' type Props = PropsRuntime<'sidebar.right.pane.tab'> -export function FileHeader({ tab, useResource, t }: Props) { +export function FileHeader({ useTabInfo, useResource, t }: Props) { + const { tab } = useTabInfo() const meta = useResource<'file'>(tab.contentId) if (meta.status === 'failed') return

{t('failed', { code: meta.failure.code })}

return (
{tab.title} - {meta.value?.changed && }
) } @@ -87,7 +84,7 @@ A consumer presents `failed` itself: the model keeps the last value beside the f A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)). -Streams carry metadata, not content. The `file` provider's value is `{ absolutePath, version, bytes?, changed }`: `absolutePath`, `version`, and `bytes` from the Host's `stat`, `changed` raised when the Host reports an agent write and cleared by `reload`. A consumer reads the file's text itself, by page, through the Workspace Files Remote namespace ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). +Streams carry metadata, not content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). ## Limits diff --git a/docs/subsystems/client-resources.zh.md b/docs/subsystems/client-resources.zh.md index 418c9aef91..6bfbb3e447 100644 --- a/docs/subsystems/client-resources.zh.md +++ b/docs/subsystems/client-resources.zh.md @@ -8,19 +8,19 @@ ## 地址 -资源地址是 `dsh-resource:///…` 形式的 URL。host 命名协议,必须是 `ResourceProtocolMap` 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:`file` 协议的地址形如 `dsh-resource://file/session//<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<去掉前导 / 的绝对路径>`,用 [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.zh.md) 的 `fileAddressFor(sessionId, cwd, path)` 构造、`parseFileAddress(address)` 读回。模型本身只读 scheme 与 host:`protocolOf(address)` 对 `dsh-resource://` URL 返回小写 host,对其它任何字串返回 `undefined`。其它 scheme 下的地址——Sidebar 的 `sidebar://guide`——不指向资源,读作 `none`。 +资源地址是 `dsh-resource:///…` 形式的 URL。host 命名协议,必须是 `ResourceProtocolMap` 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:`file` 协议的地址形如 `dsh-resource://file/session//`,其中 path 可以相对工作区根,也可以是保留前导斜杠的绝对路径,用 [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.zh.md) 的 `fileAddressFor(sessionId, cwd, path)` 构造、`parseFileAddress(address)` 读回。模型本身只读 scheme 与 host:`protocolOf(address)` 对 `dsh-resource://` URL 返回小写 host,对其它任何字串返回 `undefined`。其它 scheme 下的地址——Sidebar 的 `sidebar://guide`——不指向资源,读作 `none`。 | 地址 | 协议键 | 读作 | |---|---|---| | `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) | -| `dsh-resource://file/absolute/home/me/notes.md` | `file` | 该绝对路径的元数据,经当前会话读取、受其工作区限制 | +| `dsh-resource://file/absolute/home/me/notes.md` | `file` | 可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败;不借用当前或 Tab Session | | `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 | | `sidebar://guide` | — | `none`:导航地址 | | `/home/me/notes.md` | — | `none`:不是 URL | ## 注册提供方 -协议拥有者在 `ResourceProtocolMap` 上声明其值类型,并在自己的 `ctx.effect` 里注册一个提供方,使协议与插件同寿([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。`open(address, { signal })` 返回一条 `RemoteResult` 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 `signal` 中止时停下。失败是携带 `RemoteFailure` 的 `ok: false` 帧;流里抛出是编程错误,不会被捕获。`reload(address)` 可选,请已打开的流给一个新帧。 +协议拥有者在 `ResourceProtocolMap` 上声明其值类型,并在自己的 `ctx.effect` 里注册一个提供方,使协议与插件同寿([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。`open(address, { signal })` 返回一条 `RemoteResult` 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 `signal` 中止时停下。失败是携带 `RemoteFailure` 的 `ok: false` 帧;流里抛出是编程错误,不会被捕获。 ```ts ignore-check import type { Context } from '@deepseek-ai/cordis' @@ -43,7 +43,6 @@ export function apply(ctx: Context): void { yield await ctx.remote.notes.read(id, signal) for await (const change of ctx.remote.notes.follow(id, signal)) yield change }, - reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) }, }), 'my-notes: note resource provider') } ``` @@ -61,21 +60,19 @@ export function apply(ctx: Context): void { | `live` | 最新一帧成功 | 最新的 `ok` 值 | `undefined` | | `failed` | 最新一帧报告了失败 | 保留的上一个 `ok` 值 | 该帧的 `RemoteFailure` | -`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作;该函数按地址引用稳定,正文可以长期持有。 - ```tsx ignore-check import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-workspace-files/client' type Props = PropsRuntime<'sidebar.right.pane.tab'> -export function FileHeader({ tab, useResource, t }: Props) { +export function FileHeader({ useTabInfo, useResource, t }: Props) { + const { tab } = useTabInfo() const meta = useResource<'file'>(tab.contentId) if (meta.status === 'failed') return

{t('failed', { code: meta.failure.code })}

return (
{tab.title} - {meta.value?.changed && }
) } @@ -87,7 +84,7 @@ export function FileHeader({ tab, useResource, t }: Props) { 资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 -流只推元数据不推内容。`file` 提供方的值是 `{ absolutePath, version, bytes?, changed }`:`absolutePath`、`version` 与 `bytes` 来自 Host 的 `stat`,`changed` 在 Host 报告 agent 写入时置起、由 `reload` 清除。消费方自己经 Workspace Files Remote 命名空间按页读文件文本([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。 +流只推元数据不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。 ## 限制 diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index 93a8216e4b..c6b7cd2682 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/compaction.md -compaction.md: b49957a5f476a02ccd12b791f287a9675073c0ae -compaction.zh.md: 4b4c6845b8ff48bce019b352a811f968628cbdbf +compaction.md: 6a8743feac0481626f171e3926c7f924508db54a +compaction.zh.md: 1b8d2a2d1e47067583593ca3fae039bc78ed06f9 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index b49957a5f4..6a8743feac 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -8,7 +8,7 @@ Source: [`packages/compaction/compaction/src/types.ts`](../../packages/compactio ## The `compaction/*` session events -Compaction extends [`SessionEventMap`](session.md) with three event types via declaration merging. All three are **log-only** — they record the lock, summary, selected range, shadowed event seqs, token count, and model call without joining the surface. `SurfaceEventType` is deliberately NOT extended (only message-producing events reach the model), so the summary itself rides on a separate `user/message` with `surfaceOp: { op: 'replace', start, end }` — the only surface mutation performed by summary compaction. The [Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) owns the rationale for reusing `user/message`. +Compaction extends [`SessionEventMap`](session.md) with three event types via declaration merging. All three are **log-only** — they record the lock, summary, selected range, shadowed event seqs, token count, and model call without joining the surface. `SurfaceEventType` is deliberately NOT extended (only message-producing events reach the model), so the summary itself rides on a separate `user/message` with `surfaceOp: { op: 'replace', startSeq, endSeq }` — the only surface mutation performed by summary compaction. The [Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) owns the rationale for reusing `user/message`. | Event | Payload | Role | |---|---|---| diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index 4b4c6845b8..1b8d2a2d1e 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -8,7 +8,7 @@ ## `compaction/*` 会话事件 -压缩通过声明合并为 [`SessionEventMap`](session.zh.md) 扩展三种事件类型。三者都**仅写入日志**——它们记录锁、摘要、选中范围、被遮蔽事件 seq、token 数以及模型调用,绝不进入 surface。这里有意不扩展 `SurfaceEventType`(只有产生消息的事件才到达模型),因此摘要本身承载在另一条带有 `surfaceOp: { op: 'replace', start, end }` 的 `user/message` 上——这是摘要压缩执行的唯一 surface 变更。[Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md) 负责复用 `user/message` 的决策依据。 +压缩通过声明合并为 [`SessionEventMap`](session.zh.md) 扩展三种事件类型。三者都**仅写入日志**——它们记录锁、摘要、选中范围、被遮蔽事件 seq、token 数以及模型调用,绝不进入 surface。这里有意不扩展 `SurfaceEventType`(只有产生消息的事件才到达模型),因此摘要本身承载在另一条带有 `surfaceOp: { op: 'replace', startSeq, endSeq }` 的 `user/message` 上——这是摘要压缩执行的唯一 surface 变更。[Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md) 负责复用 `user/message` 的决策依据。 | 事件 | 载荷 | 作用 | |---|---|---| diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 25c3042d2f..07faf5ab83 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 4af3a22478f324655dea1b132f0c8a8528f4b883 -core.zh.md: 7359e5aaa75e9f750d44f1386acab388665e1e1e +core.md: 27ff30a9e63c86ffb54ccf57dea18ebf3fe39846 +core.zh.md: cedd113de5d5d551b8b558f9f33c7cacf6aa4953 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 4af3a22478..27ff30a9e6 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -46,9 +46,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) composes the agent's scoped world while both ids are still unpublished — everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly — and may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. +`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: an optional live `parentAgent`, session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `parentAgent`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` while both ids are still unpublished: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. -`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. +`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. A runtime child creator sets `options.parentAgent`; the registry passes the options and caller Context to the factory without deriving one from the other. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. ## The agent handle @@ -347,7 +347,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. Every entry carries a monotonic `seq`, a `time`, and a `type`-discriminated `data` payload; surface variants may also list cited earlier events in `sourceEventSeqs` and carry a `surfaceOp`. -The `SessionEvent` envelope's exact conditional fields, the twelve core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/attempt`, `tool/call`, `tool/result`, `request/header`, `request/context`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL provider, `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The `SessionEvent` envelope's exact conditional fields, the thirteen core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `system/message`, `assistant/message`, `assistant/attempt`, `tool/call`, `tool/result`, `request/header`, `request/context`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL provider, `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## `ToolDefinition` @@ -463,7 +463,7 @@ async create(id: SessionId, options: AgentOptions = {}, meta: Pick @@ -471,7 +471,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise @@ -736,7 +736,8 @@ Initiator methods provide same-process causal attribution only. Ambient presence * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution * that also supports agentless calls. When a parent creates a child, setup - * reports the causal parent while `agentCtx.agent` identifies the child. + * reports the causal parent while the setup callback's Agent parameter + * identifies the child. * @returns the inherited Agent, or `undefined` outside an initiator boundary * and inside an explicit clearing boundary. * @throws when this service instance has been disposed. @@ -801,7 +802,7 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, session seed/metadata, and agent options. + * @param options - shared identity, optional live parent, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise @@ -810,7 +811,7 @@ async create(options: CreateAgentOptions): Promise * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, configuration, and optional setup. + * @param options - persisted identity, optional live parent, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise @@ -822,7 +823,8 @@ async resume(options: ResumeAgentOptions): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). Returns the disposer. + * requires passing the carrier). The entry is a runtime root; factory-backed + * creation uses `options.parentAgent` for child ownership. Returns the disposer. * @param agent - the already-constructed agent to record in the store. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact @@ -842,7 +844,7 @@ register(agent: Agent): () => void * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - live agent whose scoped context created this agent, or + * @param owner - explicitly supplied live runtime owner, or * undefined for a top-level runtime root. This is runtime ownership, not * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits @@ -1087,14 +1089,18 @@ Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/s #### `agent/request` — waterfall -Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. Model-visible content must use logged channels; this waterfall cannot mutate messages. +Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. On step admission, this runs after assembly and `step/start`, before the system prompt and accepted user batch are committed. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages. ```ts cordis-catalog /** * Replace the frozen call configuration. `await next()` yields the config * the machine would use (agent options on the first request, the logged - * header afterwards); return a replacement to switch. Model-visible - * content must use logged channels; this waterfall cannot mutate messages. + * header afterwards); return a replacement to switch. On step admission, + * this runs after assembly and `step/start`, before the system prompt and + * accepted user batch are committed. Cancellation here or during subsequent + * `prepareCall()` resolution commits neither. The prepared call capability + * governs prompt admission. Model-visible content must use logged channels; + * this waterfall cannot mutate messages. * @param payload.agent - the agent making the model call. * @param payload.turn - the open turn number. * @param payload.step - the step whose request this is. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 7359e5aaa7..cedd113de5 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -48,9 +48,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时组装 agent 的作用域世界——凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在——并可返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 +`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:可选的存活 `parentAgent`、会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`parentAgent`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 均未发布时接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 -`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 +`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。运行时子 Agent 的创建方设置 `options.parentAgent`;注册表把 options 与调用方 Context 传给工厂,不从其中一项推导另一项。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 @@ -355,7 +355,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM 消息历史从日志*派生*(`deriveMessages()`),而非单独存储。每个条目携带单调的 `seq`、`time` 与按 `type` 判别的 `data` payload;surface 变体还可以在 `sourceEventSeqs` 中列出被引用的较早事件,并携带 `surfaceOp`。 -`SessionEvent` 信封的确切条件字段、十二种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/message`、`assistant/attempt`、`tool/call`、`tool/result`、`request/header`、`request/context`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL provider、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。 +`SessionEvent` 信封的确切条件字段、十三种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`system/message`、`assistant/message`、`assistant/attempt`、`tool/call`、`tool/result`、`request/header`、`request/context`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL provider、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。 ## `ToolDefinition` @@ -473,7 +473,7 @@ async create(id: SessionId, options: AgentOptions = {}, meta: Pick @@ -481,7 +481,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise @@ -746,7 +746,8 @@ Initiator methods provide same-process causal attribution only. Ambient presence * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution * that also supports agentless calls. When a parent creates a child, setup - * reports the causal parent while `agentCtx.agent` identifies the child. + * reports the causal parent while the setup callback's Agent parameter + * identifies the child. * @returns the inherited Agent, or `undefined` outside an initiator boundary * and inside an explicit clearing boundary. * @throws when this service instance has been disposed. @@ -811,7 +812,7 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, session seed/metadata, and agent options. + * @param options - shared identity, optional live parent, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise @@ -820,7 +821,7 @@ async create(options: CreateAgentOptions): Promise * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, configuration, and optional setup. + * @param options - persisted identity, optional live parent, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise @@ -832,7 +833,8 @@ async resume(options: ResumeAgentOptions): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). Returns the disposer. + * requires passing the carrier). The entry is a runtime root; factory-backed + * creation uses `options.parentAgent` for child ownership. Returns the disposer. * @param agent - the already-constructed agent to record in the store. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact @@ -852,7 +854,7 @@ register(agent: Agent): () => void * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - live agent whose scoped context created this agent, or + * @param owner - explicitly supplied live runtime owner, or * undefined for a top-level runtime root. This is runtime ownership, not * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits @@ -1097,14 +1099,18 @@ Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/s #### `agent/request` — waterfall -Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. Model-visible content must use logged channels; this waterfall cannot mutate messages. +Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. On step admission, this runs after assembly and `step/start`, before the system prompt and accepted user batch are committed. Cancellation here or during subsequent `prepareCall()` resolution commits neither. The prepared call capability governs prompt admission. Model-visible content must use logged channels; this waterfall cannot mutate messages. ```ts cordis-catalog /** * Replace the frozen call configuration. `await next()` yields the config * the machine would use (agent options on the first request, the logged - * header afterwards); return a replacement to switch. Model-visible - * content must use logged channels; this waterfall cannot mutate messages. + * header afterwards); return a replacement to switch. On step admission, + * this runs after assembly and `step/start`, before the system prompt and + * accepted user batch are committed. Cancellation here or during subsequent + * `prepareCall()` resolution commits neither. The prepared call capability + * governs prompt admission. Model-visible content must use logged channels; + * this waterfall cannot mutate messages. * @param payload.agent - the agent making the model call. * @param payload.turn - the open turn number. * @param payload.step - the step whose request this is. diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 9b4bcd724f..ff5cdfed88 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 97062fb326a2718daf33b19b2f7f00175a2ec1fa -llm-streaming.zh.md: 9f4dc7d32bee62f55e971afb44905141cabe4e80 +llm-streaming.md: cadfda5553ca5d5e0df78f40b6a7a75ef7cc71a6 +llm-streaming.zh.md: 5f73d544459268ce83a30218b710dd7c29f7a614 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 97062fb326..cadfda5553 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -492,6 +492,8 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ + error?: string } ``` @@ -511,7 +513,7 @@ interface LlmModelInfo { } ``` -Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity, adapter call defaults, and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution. +Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity, adapter call defaults, reasoning choices, and the system prompt update mode share one exact-model result so consumers do not repeat authoritative model resolution. `SystemPromptUpdate` has the single value `'in-history'`: the model reads the latest `system` message at any position of `messages` as the complete effective system prompt, so the agent loop can append a changed prompt after the cached history instead of rewriting message 0 ([decision rule](../../packages/core/agent-loop/README.md#understand-the-implementation)); an absent mode means only a leading system message is read, and `normalizeModelInfo` rejects any other value with `INVALID_MODEL_INFO`. ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -562,6 +564,8 @@ interface LlmResolvedModelInfo extends LlmModelInfo { defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo + /** Declared mid-conversation system prompt handling; absent means only a leading system message is read. */ + systemPromptUpdate?: SystemPromptUpdate } ``` @@ -574,12 +578,16 @@ interface GenerateOptions { /** Adapter-owned reasoning effort selected for this exact model. */ reasoningEffort?: ReasoningEffortId /** - * Ordered conversation messages, exactly as the provider sees them (after - * the `system` slot). A loop-built request assembles them as - * the derived history (dsh-agent-loop); a hand-built one-shot passes any list. + * Ordered conversation messages, exactly as the provider sees them. A + * loop-built request passes the derived history (dsh-agent-loop), whose + * leading system-role message carries the system prompt; a hand-built + * one-shot passes any list. */ messages: Message[] - /** System prompt text (adapters map to the provider's system slot). */ + /** + * System prompt text for one-shot callers; adapters map it to the provider's + * system slot ahead of `messages`. Loop-built requests leave it undefined. + */ system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] @@ -693,11 +701,11 @@ interface LlmDiscoveredModel { ### The request envelope: `LlmCallConfig` and the logged header -The loop builds each request from logged state. `EpochHeader` records call config, marks the fields supplied by adapter defaults, and records the rendered prompt and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). +The loop builds each request from logged state. `EpochHeader` records call config, marks the fields supplied by adapter defaults, and records the authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. The rendered prompt is derived history — the `system/message` at surface node 0, plus any later system node an `in-history` route appended — so the header and the derived history together make the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. On step admission, this waterfall and preparation run after assembly and `step/start` but before the system prompt and accepted user batch are committed; cancellation during either commits neither. The prepared capability governs prompt reconciliation, and the call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. -On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history. The logged request snapshot ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. +On the wire, a loop-built request is the derived history alone: the rendered prompt travels as the leading `system`-role message (surface node 0, a `system/message` event) and, when the prepared call declares `systemPromptUpdate: 'in-history'`, a non-empty changed prompt may follow the cached history as a later `system`-role message that the model reads as the effective prompt; the request's `system` field is unset — `GenerateOptions.system` serves direct one-shot callers such as title providers. An empty rendering leaves no system messages in derived history, even when earlier requests retained several prompt versions. The logged request ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request and rejects a loop request carrying a `system` field. FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). @@ -750,6 +758,8 @@ interface PreparedLlmCall { readonly context?: LlmModelContext /** Exact model modalities captured with the adapter dispatch generation. */ readonly inputModalities?: readonly ModelModality[] + /** Exact model system prompt update mode captured with the adapter dispatch generation. */ + readonly systemPromptUpdate?: SystemPromptUpdate /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 9f4dc7d32b..5f73d54445 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -498,6 +498,8 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ + error?: string } ``` @@ -517,7 +519,7 @@ interface LlmModelInfo { } ``` -对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量、适配器调用默认值和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。 +对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量、适配器调用默认值、推理选项和系统提示词更新模式共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。`SystemPromptUpdate` 只有一个值 `'in-history'`:模型把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词,因此 agent loop 可以把变化后的提示词追加到已缓存历史之后,而不是改写第 0 条消息([决策规则](../../packages/core/agent-loop/README.zh.md#understand-the-implementation));模式缺失表示只读取开头的 system 消息,`normalizeModelInfo` 以 `INVALID_MODEL_INFO` 拒绝任何其他值。 ```ts type-equiv /** Provider-owned context capacity for one exact provider/model route. */ @@ -568,6 +570,8 @@ interface LlmResolvedModelInfo extends LlmModelInfo { defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo + /** Declared mid-conversation system prompt handling; absent means only a leading system message is read. */ + systemPromptUpdate?: SystemPromptUpdate } ``` @@ -580,12 +584,16 @@ interface GenerateOptions { /** Adapter-owned reasoning effort selected for this exact model. */ reasoningEffort?: ReasoningEffortId /** - * Ordered conversation messages, exactly as the provider sees them (after - * the `system` slot). A loop-built request assembles them as - * the derived history (dsh-agent-loop); a hand-built one-shot passes any list. + * Ordered conversation messages, exactly as the provider sees them. A + * loop-built request passes the derived history (dsh-agent-loop), whose + * leading system-role message carries the system prompt; a hand-built + * one-shot passes any list. */ messages: Message[] - /** System prompt text (adapters map to the provider's system slot). */ + /** + * System prompt text for one-shot callers; adapters map it to the provider's + * system slot ahead of `messages`. Loop-built requests leave it undefined. + */ system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] @@ -699,11 +707,11 @@ interface LlmDiscoveredModel { ### 请求信封:`LlmCallConfig` 与记录的 header -循环从已记录状态构建每个请求。`EpochHeader` 记录调用配置,标记由适配器默认值提供的字段,并通过完整的 `request/header` 快照记录渲染后的提示词以及权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.zh.md#the-request-header-event-requestheader) 与[可重建性 Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。 +循环从已记录状态构建每个请求。`EpochHeader` 记录调用配置,标记由适配器默认值提供的字段,并通过完整的 `request/header` 快照记录权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。渲染后的提示词是派生历史——surface 第 0 号节点上的 `system/message`,加上 `in-history` 路由追加的任何后续系统节点——因此请求头与派生历史共同使请求可由会话日志重建。见 [session.md](session.zh.md#the-request-header-event-requestheader) 与[可重建性 Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。步骤准入时,该 waterfall 与准备过程在组装和 `step/start` 之后、系统提示词与已接纳用户批次提交之前运行;在任一阶段取消都不会提交这两者。已准备调用的能力决定提示词协调,调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议中,循环构建的请求先读取 `system` slot(渲染后的提示词组装),再读取派生历史。已记录的请求快照会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。开发不变式针对每个循环构建的请求精确重算此等式。 +在协议中,循环构建的请求只有派生历史:渲染后的提示词作为开头的 `system` 角色消息(surface 第 0 号节点,即一个 `system/message` 事件)传输,并且当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,变化后的非空提示词可以作为后续的 `system` 角色消息跟在已缓存历史之后,由模型读作有效提示词;请求的 `system` 字段不设置——`GenerateOptions.system` 服务于标题提供方等直接单次调用方。空渲染文本使派生历史不包含任何系统消息,即使先前请求保留了多个提示词版本。已记录的请求会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。开发不变式针对每个循环构建的请求精确重算此等式,并拒绝携带 `system` 字段的循环请求。 FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 @@ -756,6 +764,8 @@ interface PreparedLlmCall { readonly context?: LlmModelContext /** Exact model modalities captured with the adapter dispatch generation. */ readonly inputModalities?: readonly ModelModality[] + /** Exact model system prompt update mode captured with the adapter dispatch generation. */ + readonly systemPromptUpdate?: SystemPromptUpdate /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 592b0599b3..26fee2e323 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 90051ca8668173daab4a003cf574f5ec64073223 -session.zh.md: 5de78f22697f71ec8c15824d36b4b93f31fc6a94 +session.md: de0930ea7effcba69bc1f9a4dd405ceda23919d7 +session.zh.md: ec15dbdec3a8887d8fa87c9698b8ad14699a534b diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 90051ca866..de0930ea7e 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -53,6 +53,19 @@ interface SessionEventMap { * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage + /** + * The rendered system prompt on the model-visible surface. The loop appends + * the first one as surface node 0 before the step's first `user/message`. + * A prepared in-history route can append nonempty changes in a continuing + * series. An incapable route or new series normalizes text to the first system + * node. Normalization empties nonempty later nodes, then rewrites the head if + * needed, through logged per-node replacements. An empty rendering always + * clears all active system nodes, leaving no older instructions model-visible. + * Empty later nodes are dormant and project to no message; an empty head with + * no active later node records "no system prompt". Restored nonempty text follows + * the same route and series rule; empty nodes never restore older text. + */ + 'system/message': { turn: number; step: number; message: SystemMessage } /** * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so @@ -99,6 +112,7 @@ interface SessionEventMap { turn: number step: number message: ToolResultMessage + /** Optional failure identity; allowed only when the tool-result block has `isError: true`. */ error?: { name: string; code: string } meta?: JsonValue } @@ -113,8 +127,10 @@ interface SessionEventMap { startsSeries?: true } /** - * Route metadata for the next request, logged only when the route or capacity - * changes. It does not participate in request reconstruction or header equality. + * Route metadata for the next request, logged only when the route, capacity, + * or system prompt update mode changes. It does not participate in request + * reconstruction or header equality. Prompt admission uses the bound prepared + * call's capability, not this snapshot from an earlier request. */ 'request/context': RequestContext /** @@ -149,12 +165,13 @@ interface SessionEventMap { ### The request header event: `request/header` -The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a changed request appends a snapshot with reason `'change'`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a snapshot with reason `'series'`. A changed snapshot carries `startsSeries: true` when that request also begins a series. Ordinary append-only later Turns, further Steps, and retries in the same model-message series inherit the latest snapshot. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. +The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). The rendered system prompt is not part of the header: it is derived history, the `system/message` event at surface node 0 and any later in-history system node ([decision](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)), so a prompt change replaces or appends a system node and leaves the header unchanged. A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a changed request appends a snapshot with reason `'change'`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a snapshot with reason `'series'`. A changed snapshot carries `startsSeries: true` when that request also begins a series. Ordinary append-only later Turns, further Steps, and retries in the same model-message series inherit the latest snapshot. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. ```ts type-equiv /** - * Logged request state outside derived history: call config, system prompt, and - * tools. The latest full `request/header` snapshot reconstructs it; canonical + * Logged request state outside derived history: call config and tools. The + * system prompt is derived history — surface node 0, a `system/message` event. + * The latest full `request/header` snapshot reconstructs the header; canonical * empty optional fields are absent. */ interface EpochHeader { @@ -162,18 +179,16 @@ interface EpochHeader { config: LlmCallConfig /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */ adapterDefaults?: LlmCallConfigAdapterDefaults - /** Rendered system prompt text; absent for a system-less request. */ - system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] } ``` -Canonical form represents an empty system prompt or tool list as an absent field, matching how requests are built. Legacy v0 logs containing the legacy `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely. +Current event acceptance requires canonical `request/header.header`: any `system` field is forbidden, and `tools: []` and `adapterDefaults: {}` must be omitted. Whitespace-only system-message content, `config.stop: []`, and nested extensions remain unchanged. Seed, append, and current persistence reads reject noncanonical headers rather than silently normalizing them; [the V3 envelope decision](../../.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md) owns historical conversion. Legacy v0 logs containing `request/header-delta` or its full-snapshot `fallback` reason are rejected rather than replayed incompletely. ### The route capacity event: `request/context` -The context metadata of the route a request resolved to is separate logged state, appended beside `request/header` inside the same step and only when the provider, model, or capacity differs from the previous record. It stays outside `EpochHeader` because that type is the reconstruction contract compared field-wise by `headerEquals`: capacity describes a route, not a request input, so folding it in would let a capacity change register as a request-envelope `change` and would pull adapter metadata into the loop's reconstruction invariant. Like `request/header`, it is not a `SurfaceEventType` and produces no LLM message. `session.requestContext()` folds the latest record incrementally. A route whose adapter advertises no capacity is recorded with `contextWindow` absent, so the new record clears an older route's capacity. +The context metadata of the route a request resolved to is separate logged state, appended beside `request/header` inside the same step and only when the provider, model, capacity, or `systemPromptUpdate` mode differs from the previous record. It stays outside `EpochHeader` because that type is the reconstruction contract compared field-wise by `headerEquals`: capacity and the update mode describe a route, not a request input, so folding them in would let a route change register as a request-envelope `change` and would pull adapter metadata into the loop's reconstruction invariant. Like `request/header`, it is not a `SurfaceEventType` and produces no LLM message. `session.requestContext()` folds the latest record incrementally; the agent loop reads that record's `systemPromptUpdate` when it decides whether a changed system prompt replaces the latest system node or is appended after the cached history ([decision rule](../../packages/core/agent-loop/README.md#understand-the-implementation)). A route whose adapter advertises no capacity is recorded with `contextWindow` absent, so the new record clears an older route's capacity; a route without a declared update mode likewise clears an older route's `systemPromptUpdate`. ```ts type-equiv /** Registration-bound metadata for one resolved model route. */ @@ -184,6 +199,8 @@ interface RequestContext { model: string /** Maximum combined request and response context in tokens, when advertised. */ contextWindow?: number + /** `'in-history'` when the route reads the latest `system` message at any position as the effective system prompt. */ + systemPromptUpdate?: SystemPromptUpdate } ``` @@ -221,7 +238,7 @@ type OptionalSessionSeq = SessionSeq | null * unions), so `switch (event.type)` narrows `event.data` without casts. * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: - * they only exist on {@link SurfaceEventType} variants (`user/message`, + * they only exist on {@link SurfaceEventType} variants (`system/message`, `user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, attempts, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` @@ -246,26 +263,20 @@ type SessionEvent = { * inconvenience) rather than silently resuming a gutted session. */ ignorable?: true - } & (K extends SurfaceEventType ? { - /** - * Seq numbers of earlier events that this event cites as sources, such as - * the surface nodes shadowed by a compaction replacement. A v2 - * `assistant/message` embeds its provider stream and cannot carry this field. - */ - sourceEventSeqs?: SessionSeq[] - /** How this event entered the surface; absent for non-surface events. */ - surfaceOp?: SurfaceOp - } : object) + } & (K extends SurfaceEventType ? SurfaceIntent : { + surfaceOp?: never + sourceEventSeqs?: never + }) }[T] ``` `SessionEventType = keyof SessionEventMap`. Because `SessionEventMap` is merge-extensible, switches over `SessionEvent` must NOT use `assertNever` — a plugin-added variant is a valid unknown value; handle the known cases and fall through `default`. -V2 `assistant/message` embeds its provider stream and cannot carry `sourceEventSeqs`. User and tool surface events may cite a complete non-empty set of unique earlier events when their provenance or replacement operation requires it. +Every surface event requires `surfaceOp`; known log-only events forbid both surface metadata fields. Native unknown or obsolete ignorable envelopes remain opaque. `assistant/message` embeds its provider stream and forbids `sourceEventSeqs`. System, user, and tool surface events may cite a complete non-empty set of unique earlier events when their provenance or replacement operation requires it. A `tool/result` may carry `data.error` only when its tool-result block has `isError: true`; failure identity remains optional for failed results. ## Surface types -The three message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). +The four message-producing types (`SurfaceEventType` — `system/message`, `user/message`, `assistant/message`, `tool/result`) carry surface metadata declaring how they join the ordered derived surface. `system/message` holds the rendered system prompt: the loop appends the first one as surface node 0 and, when the prompt changes, replaces exactly the latest system node or appends a new one on an in-history route; the surface fold rejects any other replacement covering a `system/message` at node 0, while a later system node is ordinary history that a compaction replacement may shadow. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). ### `SurfaceEventType` — the message-producing subset of event types @@ -273,10 +284,11 @@ The three message-producing types (`SurfaceEventType` — `user/message`, `assis /** * The subset of {@link SessionEventType} values whose events produce LLM * messages and are eligible to appear on the ordered surface. Only these - * event types may carry {@link SurfaceOp}; user and tool events may also cite + * event types may carry {@link SurfaceOp}; system, user, and tool events may also cite * earlier sources through {@link SessionEvent.sourceEventSeqs}. */ type SurfaceEventType = + | 'system/message' | 'user/message' | 'assistant/message' | 'tool/result' @@ -291,19 +303,19 @@ type SurfaceEventType = * * - `'append'`: added to the tail — normal path for user/assistant/tool * messages. - * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` - * (inclusive) through `end` (inclusive) with this node. Both must exist as - * surface nodes in the current surface. `start === end` replaces a single + * - `{ op: 'replace', startSeq, endSeq }`: replaces surface nodes from `startSeq` + * (inclusive) through `endSeq` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `startSeq === endSeq` replaces a single * node. The node's {@link SessionEvent.sourceEventSeqs} must include every * shadowed surface node. Used by compaction; any surface-replacing producer * may use it. */ type SurfaceOp = | 'append' - | { op: 'replace'; start: SessionSeq; end: SessionSeq } + | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq } ``` -`'append'` is the normal tail-append path. `replace` shadows surface entries from `start` through `end` inclusive (both must be valid surface seqs; `start === end` replaces a single entry) and inserts the new event in their place. +`'append'` is the normal tail-append path. `replace` contains exactly `op`, `startSeq`, and `endSeq`, with no aliases or extra keys. It shadows the inclusive span between those current surface event sequences and inserts the new event in their place; equal endpoints replace one entry. Endpoints must precede the replacing event, but their relative order is surface order, not numeric sequence order. ### `SurfaceIntent` — the parameter to `session.append()` @@ -315,7 +327,7 @@ type SurfaceOp = type SurfaceIntent = { surfaceOp: SurfaceOp } & (T extends 'assistant/message' ? { - /** V2 Assistant messages embed their provider stream instead of citing source events. */ + /** Assistant messages embed their provider stream instead of citing source events. */ sourceEventSeqs?: never } : { /** Complete non-empty set of known earlier source-event seqs. */ @@ -516,6 +528,7 @@ declare class Session { * (BigInt, function, symbol, undefined, negative zero, non-finite number, * circular reference, sparse array, or an exotic object such as * Map/Set/Date/class instance), or when the candidate violates the + * request-header empty-field or tool-error consistency rules, or the * canonical surface contract (marker shape and eligibility, unique * earlier source-event references, positional replacement validity, and complete * shadowed-node coverage). One iterative pass reads, validates, and diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 5de78f2269..ec15dbdec3 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -53,6 +53,19 @@ interface SessionEventMap { * project their `content` verbatim; `source` tells them apart. */ 'user/message': UserMessage + /** + * The rendered system prompt on the model-visible surface. The loop appends + * the first one as surface node 0 before the step's first `user/message`. + * A prepared in-history route can append nonempty changes in a continuing + * series. An incapable route or new series normalizes text to the first system + * node. Normalization empties nonempty later nodes, then rewrites the head if + * needed, through logged per-node replacements. An empty rendering always + * clears all active system nodes, leaving no older instructions model-visible. + * Empty later nodes are dormant and project to no message; an empty head with + * no active later node records "no system prompt". Restored nonempty text follows + * the same route and series rule; empty nodes never restore older text. + */ + 'system/message': { turn: number; step: number; message: SystemMessage } /** * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so @@ -99,6 +112,7 @@ interface SessionEventMap { turn: number step: number message: ToolResultMessage + /** Optional failure identity; allowed only when the tool-result block has `isError: true`. */ error?: { name: string; code: string } meta?: JsonValue } @@ -113,8 +127,10 @@ interface SessionEventMap { startsSeries?: true } /** - * Route metadata for the next request, logged only when the route or capacity - * changes. It does not participate in request reconstruction or header equality. + * Route metadata for the next request, logged only when the route, capacity, + * or system prompt update mode changes. It does not participate in request + * reconstruction or header equality. Prompt admission uses the bound prepared + * call's capability, not this snapshot from an earlier request. */ 'request/context': RequestContext /** @@ -149,12 +165,13 @@ interface SessionEventMap { ### 请求头事件:`request/header` -请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason 为 `'change'` 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 +请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。渲染后的系统提示词不属于请求头:它是派生历史,即 surface 第 0 号节点上的 `system/message` 事件以及任何后续的历史内系统节点([决策](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md)),因此提示词变更替换或追加一个系统节点,而请求头保持不变。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason 为 `'change'` 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 ```ts type-equiv /** - * Logged request state outside derived history: call config, system prompt, and - * tools. The latest full `request/header` snapshot reconstructs it; canonical + * Logged request state outside derived history: call config and tools. The + * system prompt is derived history — surface node 0, a `system/message` event. + * The latest full `request/header` snapshot reconstructs the header; canonical * empty optional fields are absent. */ interface EpochHeader { @@ -162,18 +179,16 @@ interface EpochHeader { config: LlmCallConfig /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */ adapterDefaults?: LlmCallConfigAdapterDefaults - /** Rendered system prompt text; absent for a system-less request. */ - system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] } ``` -规范形式:空系统提示词和空工具列表都表示为字段缺失,与请求构建方式一致。包含旧版 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 +当前事件接纳要求 `request/header.header` 为规范形式:禁止任何 `system` 字段,必须省略 `tools: []` 与 `adapterDefaults: {}`。仅含空白的系统消息内容、`config.stop: []` 与嵌套扩展保持不变。seed、append 与当前持久化读取拒绝非规范 header,而不会静默规范化;[V3 信封决策](../../.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md)负责历史转换。包含旧版 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会被拒绝,而不会以不完整方式回放。 ### 路由容量事件:`request/context` -请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是 `headerEquals` 逐字段比较的重建约定。容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量。 +请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型、容量或 `systemPromptUpdate` 模式与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是 `headerEquals` 逐字段比较的重建约定。容量与更新模式描述的是路由,不是请求输入,把它们折叠进去会让一次路由变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录;agent loop 在决定变化后的系统提示词是替换最新的系统节点还是追加到已缓存历史之后时,读取该记录的 `systemPromptUpdate`([决策规则](../../packages/core/agent-loop/README.zh.md#understand-the-implementation))。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量;未声明更新模式的路由同样会清除较早路由的 `systemPromptUpdate`。 ```ts type-equiv /** Registration-bound metadata for one resolved model route. */ @@ -184,6 +199,8 @@ interface RequestContext { model: string /** Maximum combined request and response context in tokens, when advertised. */ contextWindow?: number + /** `'in-history'` when the route reads the latest `system` message at any position as the effective system prompt. */ + systemPromptUpdate?: SystemPromptUpdate } ``` @@ -221,7 +238,7 @@ type OptionalSessionSeq = SessionSeq | null * unions), so `switch (event.type)` narrows `event.data` without casts. * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: - * they only exist on {@link SurfaceEventType} variants (`user/message`, + * they only exist on {@link SurfaceEventType} variants (`system/message`, `user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, attempts, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` @@ -246,28 +263,22 @@ type SessionEvent = { * inconvenience) rather than silently resuming a gutted session. */ ignorable?: true - } & (K extends SurfaceEventType ? { - /** - * Seq numbers of earlier events that this event cites as sources, such as - * the surface nodes shadowed by a compaction replacement. A v2 - * `assistant/message` embeds its provider stream and cannot carry this field. - */ - sourceEventSeqs?: SessionSeq[] - /** How this event entered the surface; absent for non-surface events. */ - surfaceOp?: SurfaceOp - } : object) + } & (K extends SurfaceEventType ? SurfaceIntent : { + surfaceOp?: never + sourceEventSeqs?: never + }) }[T] ``` `SessionEventType = keyof SessionEventMap`。由于 `SessionEventMap` 可通过合并扩展,对 `SessionEvent` 的 switch 语句禁止使用 `assertNever`:插件添加的变体是合法的未知值;处理已知 case 后在 `default` 中放行。 -V2 `assistant/message` 嵌入 provider stream,不能携带 `sourceEventSeqs`。User 与 tool surface event 可以在 provenance 或 replacement operation 需要时引用完整且非空的唯一较早 event 集合。 +每个 surface 事件都要求 `surfaceOp`;已知仅日志事件禁止两个 surface 元数据字段。原生未知或已退役的可忽略信封保持不透明。`assistant/message` 嵌入其提供方 stream,并禁止 `sourceEventSeqs`。System、user 与 tool surface 事件可以在来源或替换操作需要时引用完整、非空且唯一的较早事件集合。`tool/result` 仅在工具结果块带有 `isError: true` 时可以携带 `data.error`;失败结果的失败身份仍可省略。 ## Surface 类型 -三种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md)。 +四种产生消息的类型(`SurfaceEventType`:`system/message`、`user/message`、`assistant/message`、`tool/result`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。`system/message` 承载渲染后的系统提示词:循环把第一条追加为 surface 第 0 号节点,并在提示词变化时恰好替换最新的系统节点,或在历史内路由上追加一条新的;surface 折叠拒绝任何其他覆盖第 0 号节点 `system/message` 的替换,而后续系统节点是普通历史,压缩替换可以遮蔽它。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md)。 ### `SurfaceEventType`:事件类型中产生消息的子集 @@ -275,10 +286,11 @@ V2 `assistant/message` 嵌入 provider stream,不能携带 `sourceEventSeqs` /** * The subset of {@link SessionEventType} values whose events produce LLM * messages and are eligible to appear on the ordered surface. Only these - * event types may carry {@link SurfaceOp}; user and tool events may also cite + * event types may carry {@link SurfaceOp}; system, user, and tool events may also cite * earlier sources through {@link SessionEvent.sourceEventSeqs}. */ type SurfaceEventType = + | 'system/message' | 'user/message' | 'assistant/message' | 'tool/result' @@ -293,19 +305,19 @@ type SurfaceEventType = * * - `'append'`: added to the tail — normal path for user/assistant/tool * messages. - * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` - * (inclusive) through `end` (inclusive) with this node. Both must exist as - * surface nodes in the current surface. `start === end` replaces a single + * - `{ op: 'replace', startSeq, endSeq }`: replaces surface nodes from `startSeq` + * (inclusive) through `endSeq` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `startSeq === endSeq` replaces a single * node. The node's {@link SessionEvent.sourceEventSeqs} must include every * shadowed surface node. Used by compaction; any surface-replacing producer * may use it. */ type SurfaceOp = | 'append' - | { op: 'replace'; start: SessionSeq; end: SessionSeq } + | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq } ``` -`'append'` 是常规的尾部追加路径。`replace` 会遮蔽从 `start` 到 `end`(含两端)的 surface 条目(两者都必须是有效的 surface seq;`start === end` 时仅替换单个条目),并在原位置插入新事件。 +`'append'` 是常规的尾部追加路径。`replace` 恰好包含 `op`、`startSeq` 和 `endSeq`,不接受别名或额外键。它遮蔽这两个当前 surface 事件序号之间的闭区间,并在原位置插入新事件;相同端点仅替换一个条目。端点必须早于替换事件,但它们的相对顺序按 surface 顺序而非数值序号顺序确定。 ### `SurfaceIntent`:`session.append()` 的参数 @@ -317,7 +329,7 @@ type SurfaceOp = type SurfaceIntent = { surfaceOp: SurfaceOp } & (T extends 'assistant/message' ? { - /** V2 Assistant messages embed their provider stream instead of citing source events. */ + /** Assistant messages embed their provider stream instead of citing source events. */ sourceEventSeqs?: never } : { /** Complete non-empty set of known earlier source-event seqs. */ @@ -518,6 +530,7 @@ declare class Session { * (BigInt, function, symbol, undefined, negative zero, non-finite number, * circular reference, sparse array, or an exotic object such as * Map/Set/Date/class instance), or when the candidate violates the + * request-header empty-field or tool-error consistency rules, or the * canonical surface contract (marker shape and eligibility, unique * earlier source-event references, positional replacement validity, and complete * shadowed-node coverage). One iterative pass reads, validates, and diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 1b16e2a532..4877bb7a0e 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: db4103a282287d8ea163d66b19efec27e0a0630f -sidebar-right.zh.md: 1cb0b1e67e99acd7cb6aa12272cd57a5fdd207e0 +sidebar-right.md: 807edc5873c97a256cb66923e776b0573af3186f +sidebar-right.zh.md: a56ebe819e8fbc63f5e6aa7697eb08e03b8d1c46 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index db4103a282..807edc5873 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -8,9 +8,9 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re ## Position and ownership -One docking surface exists per Session, held in a session-scoped slot store and drawn by the `rightbar` seat; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab. +One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and an empty root pane receives the default page selected from registered guide entries. -A tab type is two registrations that share one `kind`: a static definition in `ctx.sidebarRightTabs` saying which addresses the type opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types. +A tab type is two registrations that share the definition's `id`: a static definition in `ctx.sidebarRightTabs` saying which addresses its `kind` opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types. | Package | Role | |---|---| @@ -19,7 +19,7 @@ A tab type is two registrations that share one `kind`: a static definition in `c | [`client/resources`](../../packages/client/resources/README.md) | `ctx.resources`, `useResource`, the protocol → value roster `ResourceProtocolMap` | | [`api/workspace-files`](../../packages/api/workspace-files/README.md) | Host `ctx.workspaceFiles`, the `workspaceFiles` Remote namespace, and the Client `file` resource provider | | [`util/workspace-path`](../../packages/util/workspace-path/README.md) | The file address grammar: `fileAddressFor`, `parseFileAddress` | -| [`client/ui-sidebar-textpreview`](../../packages/client/ui-sidebar-textpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types | +| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types | ## Addresses @@ -90,7 +90,7 @@ Placement is the caller's option, never a type's property. The conversation call ## Slots and owner props -The subsystem declares four slots; a tab type registers into the first, optionally the second, and any package into the others ([hierarchy](slots.md)). +The Sidebar declares four extension slots; its document tab declares the additional keyed document body below ([hierarchy](slots.md)). | Slot | Cardinality | Purpose | |---|---|---| @@ -98,29 +98,40 @@ The subsystem declares four slots; a tab type registers into the first, optional | `sidebar.right.pane.tab.title` | keyed by the definition's `id`, Session scope | The chip's title, with the same owner share as the body. Optional: without an entry the chip shows the `title(address)` text captured at open time; a type with a live title reads its own store here. | | `sidebar.right.tab.guide` | chain, Session scope | Replaces the guide tab's contents without replacing the tab; the first non-declining entry takes the body, otherwise the shipped guide renders. | | `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. | +| `sidebar.right.tab.document` | keyed by the document implementation's `id`, Session scope | The selected file renderer inside the document tab; the parent owns shared loading and toolbar controls. | A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. Docked bodies are visible only while expanded and active; docked titles need only expansion; floats stay visible. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters. `navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)). +## Document renderers + +The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership. + +The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. `loading` is `text-pages` or `bytes-complete`; `wrap` advertises support for the shared source-wrap control. + +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, and current `wrap`: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. + +Preview records its loaded version and the version observed when a read starts. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)). + ## Resource model -The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource:///…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }), reload?(address) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch. +The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource:///…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch. -`useResource

(address)` is a global standard prop on every slot component, whatever its scope. It returns `{ status, value, failure, reload }`: `none` when the address's protocol has no provider or the address is not a resource address (`sidebar://guide` names no resource), `loading` until the first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op without one ([read a resource](../../packages/client/resources/README.md#read-a-resource)). +`useResource

(address)` is a global standard prop on every slot component, whatever its scope. It returns `{ status, value, failure }`: `none` when the address's protocol has no provider or the address is not a resource address (`sidebar://guide` names no resource), `loading` until the first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. ([read a resource](../../packages/client/resources/README.md#read-a-resource)). -A resource stays open while it has a holder — a subscribed `useResource` or a `ctx.resources.pin(address, signal)`; the first holder opens the provider's stream, later holders share it and read the latest value at once, and the last release aborts the stream and discards the value. Streams carry metadata, not content: the `file` value is `{ absolutePath, version, bytes?, changed }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)). +A resource stays open while it has a holder — a subscribed `useResource` or a `ctx.resources.pin(address, signal)`; the first holder opens the provider's stream, later holders share it and read the latest value at once, and the last release aborts the stream and discards the value. Streams carry metadata, not content: the `file` value is `{ absolutePath, version, bytes? }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)). ## Workspace Files -The Host `ctx.workspaceFiles` service and the generated `workspaceFiles` Remote namespace answer for files inside the addressed Session's workspace root: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding; `list(path)` returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set; `changes()` yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). Every call passes the same four gates — the path is inside the workspace root, symlinks are refused, page, window, and entry caps hold, `read`'s text is UTF-8 — and fails with a `workspace-file/*` error code otherwise ([failures](../../packages/api/workspace-files/README.md)). +The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes()` likewise remains workspace-scoped and yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File operations reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)). -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider and declares `ResourceProtocolMap.file`. It sends a Session address's relative path unchanged to the Host and binds change filtering to the first successful `stat.absolutePath`. It waits for the Host's `ready` frame before stat, retaining changes delivered during that read. An absolute address uses the current Session; only its absence produces Client `workspace-file/unknown-workspace`. No Client `cwd` is required. +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's ordinary Remote callbacks use the Session in that address; Host `readAll` and `readRelated` remain, and Preview's `rpc.ts` decodes byte results. ## Shipped types -- **`guide`** — `builtin`, opened as `openTab('guide')`. A centred title, one line, and one entry box per `guide` entry the registered types contributed, in `order`; picking a box opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, every new pane is seeded with one, and the strip's add control appears only while its pane has none ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). -- **`text`** — `fallback`, `dsh-resource://file/**`. Reads metadata through `useResource<'file'>` and the file's lines by page through `read`; honours `params.line` on every navigation; keeps pages, scroll, and wrap in its own store ([README](../../packages/client/ui-sidebar-textpreview/README.md)). +- **`guide`** — `builtin`, opened as `openTab('guide')`. A centred title, one line, and one entry box per `guide` entry the registered types contributed, in `order`; picking a box opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). +- **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). @@ -129,9 +140,9 @@ The Host `ctx.workspaceFiles` service and the generated `workspaceFiles` Remote - Persistence: layout state is memory-only; a reload starts every session collapsed, and no session's tabs are visible from another. - A read-only layout snapshot or subscription on `ctx.sidebarRight`: the service exposes operations only, and dockkit's `LayoutState`/`LayoutOp` are internal. - A capability-discovery array (`features`) on the service. -- An `option` priority band: nothing lists a type without letting it claim. +- An `option` priority band for tab types: nothing lists a tab type without letting it claim. - Retitling a record: `title(address)` is captured once; a live chip comes from the title slot, not from the record. -- Naming an implementation when opening: `openResource` names a kind at most, and the kind's implementation in force answers. +- Naming a tab implementation when opening: `openResource` names a kind at most; document-renderer selection belongs to the file tab's toolbar. - An address lookup on the service (`find`): a caller opens with `revealIfOpened` and lets the surface de-duplicate. - Navigation addresses beyond the Sidebar's own `sidebar://` bookkeeping; their grammar waits for the navigation controller as a whole. -- A user-facing undo, a content navigation stack, tab icons, and closing restrictions ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)). +- A user-facing undo, a content navigation stack, and tab icons ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)). diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 1cb0b1e67e..a56ebe819e 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -8,9 +8,9 @@ ## 定位与归属 -每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar` 席位绘制;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。 +每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,根 pane 为空时会加入根据已注册引导入口选出的默认页。 -一个 tab 类型是共用一个 `kind` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明该类型打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。 +一个 tab 类型是共用定义 `id` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明其 `kind` 打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。 | 包 | 职责 | |---|---| @@ -19,7 +19,7 @@ | [`client/resources`](../../packages/client/resources/README.zh.md) | `ctx.resources`、`useResource`、协议 → 值类型的花名册 `ResourceProtocolMap` | | [`api/workspace-files`](../../packages/api/workspace-files/README.zh.md) | Host `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间与 Client `file` 资源提供者 | | [`util/workspace-path`](../../packages/util/workspace-path/README.zh.md) | 文件地址语法:`fileAddressFor`、`parseFileAddress` | -| [`client/ui-sidebar-textpreview`](../../packages/client/ui-sidebar-textpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 | +| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 | ## 地址 @@ -90,7 +90,7 @@ export function apply(ctx: Context): void { ## Slot 与 owner props -本子系统声明四个 slot;tab 类型注册进第一个,可选地注册第二个,任何包都可注册进其余两个([层级](slots.zh.md))。 +Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed 文档正文 slot([层级](slots.zh.md))。 | Slot | Cardinality | 用途 | |---|---|---| @@ -98,29 +98,40 @@ export function apply(ctx: Context): void { | `sidebar.right.pane.tab.title` | 按定义的 `id` keyed,会话作用域 | chip 的标题,owner share 与正文相同。可选:没有条目时 chip 显示打开时捕获的 `title(address)` 文本;有活标题的类型在此读自己的 store。 | | `sidebar.right.tab.guide` | chain,会话作用域 | 替换引导 tab 的内容而不替换 tab;第一个不拒绝的条目接管正文,否则渲染自带引导。 | | `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 | +| `sidebar.right.tab.document` | 按文档实现的 `id` keyed,会话作用域 | 文档 tab 内选中的文件渲染器;父组件拥有共享加载与工具栏控件。 | 正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。停靠正文仅在展开且活跃时可见;停靠标题只要求展开;浮窗保持可见。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。 `navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。 +## 文档渲染器 + +`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。 + +[注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`loading` 为 `text-pages` 或 `bytes-complete`;`wrap` 声明是否支持共享的源码换行控件。 + +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content` 与当前 `wrap`:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 + +Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 + ## 资源模型 -模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource:///…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }), reload?(address) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。 +模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource:///…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。 -`useResource

(address)` 是每个 slot 组件都有的全局标准 prop,不论作用域。它返回 `{ status, value, failure, reload }`:地址协议没有提供方或地址不是资源地址(`sidebar://guide` 不指向资源)时为 `none`,首帧之前为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,没有提供方时是空操作([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。 +`useResource

(address)` 是每个 slot 组件都有的全局标准 prop,不论作用域。它返回 `{ status, value, failure }`:地址协议没有提供方或地址不是资源地址(`sidebar://guide` 不指向资源)时为 `none`,首帧之前为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。 -资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 +资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ absolutePath, version, bytes? }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 ## Workspace Files -Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间负责所寻址会话工作区根之内的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码;`list(path)` 返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`;`changes()` 在订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。每次调用都过同样四关——路径在工作区根内、拒绝符号链接、页、窗口与条目上限、`read` 的 UTF-8 文本——否则以 `workspace-file/*` 错误码失败([失败](../../packages/api/workspace-files/README.zh.md))。 +Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes()` 同样限定于工作区,订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件操作拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。 -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方并声明 `ResourceProtocolMap.file`。它把 Session 地址的相对路径原样发送给 Host,按首次成功的 `stat.absolutePath` 绑定变更过滤。它在 stat 前等待 Host 的 `ready` 帧,并保留读取期间到达的变更。绝对地址使用当前 Session;只有缺少当前 Session 时才产生 Client `workspace-file/unknown-workspace`。Client 不需要 `cwd`。 +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的普通 Remote 回调使用地址中的 Session;Host `readAll` 和 `readRelated` 保留,字节结果由 Preview 的 `rpc.ts` 解码。 ## 内置类型 -- **`guide`**——`builtin`,以 `openTab('guide')` 打开。居中标题、一行说明,以及已注册类型贡献的每个 `guide` 入口一框、按 `order` 排列;点一框即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,每个新 pane 都种入一个,tab 条的新增控件只在本 pane 没有引导时出现([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 -- **`text`**——`fallback`,`dsh-resource://file/**`。经 `useResource<'file'>` 读元数据、经 `read` 按页读文件行;每次导航都响应 `params.line`;页、滚动与换行放在自己的 store 里([README](../../packages/client/ui-sidebar-textpreview/README.zh.md))。 +- **`guide`**——`builtin`,以 `openTab('guide')` 打开。居中标题、一行说明,以及已注册类型贡献的每个 `guide` 入口一框、按 `order` 排列;点一框即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 +- **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 @@ -129,9 +140,9 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - 持久化:布局状态只在内存里;刷新后每个会话从折叠开始,任何会话的 tab 都不会出现在另一个会话里。 - `ctx.sidebarRight` 上的只读布局快照或订阅:服务只暴露操作,dockkit 的 `LayoutState`/`LayoutOp` 是内部的。 - 服务上的能力探测数组(`features`)。 -- `option` 优先级档:没有「只列出、不许认领」的类型。 +- tab 类型的 `option` 优先级档:没有「只列出、不许认领」的 tab 类型。 - 改写记录的标题:`title(address)` 只捕获一次;活的 chip 来自标题 slot,而不是记录。 -- 打开时点名某个实现:`openResource` 最多点名一个 kind,由该 kind 的生效实现应答。 +- 打开时点名某个 tab 实现:`openResource` 最多点名一个 kind;文档渲染器由文件 tab 的工具栏选择。 - 服务上的地址查找(`find`):调用方用 `revealIfOpened` 打开,由停靠面去重。 - Sidebar 自身 `sidebar://` 记账之外的导航地址;其语法等导航控制器整体做时再定。 -- 面向用户的撤销、内容导航栈、tab 图标与关闭限制([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。 +- 面向用户的撤销、内容导航栈与 tab 图标([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 49925a9aa5..d829845174 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: 136607b035c4a40c8ab96d6bf933e6b5c8a925bf -slots.zh.md: b3a54048f5df20ad3ef271aea868ce15d5e18bce +slots.md: e659a0ccff548dcf1d9aa6f422d1773a838a62ef +slots.zh.md: 92d1af68f8b67637342055d3c11aea29194048ad diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 136607b035..e659a0ccff 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -82,6 +82,7 @@ The shipped adapters add these standard props. They are available according to t |---|---|---| | every scope | `useSessions`, `useSessionPendingInteraction` | `ui-session` | | every scope | `useWorkspaces` | `ui-workspace` | +| every scope | `usePanelInfo` | `ui-layout` | | `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` | | `session-maybe` | optional `sessionId`, `useSession`, `useProjection` results | `ui-session` | | `session` | `useConversation`, `useInput`, `inputActions` | `ui-conversation` | @@ -112,6 +113,7 @@ root ├─ sidebar │ ├─ sidebar.brand.mark │ ├─ sidebar.brand.name +│ ├─ sidebar.panellist │ ├─ sidebar.footer.action │ ├─ sidebar.workspaces │ │ └─ sidebar.workspaces.directoryFlow @@ -127,43 +129,45 @@ root │ ├─ settings.models.footer │ └─ settings.plugins.tab │ └─ settings.plugin.item -├─ conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.node -│ │ │ ├─ conversation.chat.assistant-actions -│ │ │ ├─ conversation.chat.commandview -│ │ │ ├─ conversation.chat.turnTail -│ │ │ └─ tool.call.toolview -│ │ │ ├─ tool.call.images -│ │ │ └─ tool.view.cordis -│ │ ├─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ └─ conversation.approval.detail -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset +├─ main +│ └─ main.conversation +│ ├─ conversation.session +│ │ └─ conversation.view +│ │ ├─ conversation.chat.node +│ │ │ ├─ conversation.chat.assistant-actions +│ │ │ ├─ conversation.chat.commandview +│ │ │ ├─ conversation.chat.turnTail +│ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images +│ │ │ └─ tool.view.cordis +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images +│ ├─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner +│ ├─ conversation.composer +│ │ └─ conversation.approval.detail +│ ├─ conversation.composer.bar +│ │ ├─ conversation.input.attachments +│ │ ├─ conversation.input.plan +│ │ └─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.input.dock +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ ├─ conversation.input.right +│ ├─ conversation.hero.brand.mark +│ ├─ conversation.hero.workspace +│ │ └─ conversation.hero.workspace.directoryFlow +│ └─ conversation.hero.agentPreset ├─ rightbar -│ ├─ sidebar.right.pane.tab -│ │ └─ sidebar.right.tab.guide -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item +│ └─ rightbar.session +│ ├─ sidebar.right.pane.tab +│ │ └─ sidebar.right.tab.guide +│ ├─ sidebar.right.pane.tab.title +│ └─ sidebar.right.tab.menu.item └─ shell.overlay ``` diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index b3a54048f5..92d1af68f8 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -82,6 +82,7 @@ Slot 声明固定两个相互独立的维度。 |---|---|---| | 所有 scope | `useSessions`、`useSessionPendingInteraction` | `ui-session` | | 所有 scope | `useWorkspaces` | `ui-workspace` | +| 所有作用域 | `usePanelInfo` | `ui-layout` | | `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` | | `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection` | `ui-session` | | `session` | `useConversation`、`useInput`、`inputActions` | `ui-conversation` | @@ -112,6 +113,7 @@ root ├─ sidebar │ ├─ sidebar.brand.mark │ ├─ sidebar.brand.name +│ ├─ sidebar.panellist │ ├─ sidebar.footer.action │ ├─ sidebar.workspaces │ │ └─ sidebar.workspaces.directoryFlow @@ -127,43 +129,45 @@ root │ ├─ settings.models.footer │ └─ settings.plugins.tab │ └─ settings.plugin.item -├─ conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.node -│ │ │ ├─ conversation.chat.assistant-actions -│ │ │ ├─ conversation.chat.commandview -│ │ │ ├─ conversation.chat.turnTail -│ │ │ └─ tool.call.toolview -│ │ │ ├─ tool.call.images -│ │ │ └─ tool.view.cordis -│ │ ├─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ └─ conversation.approval.detail -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset +├─ main +│ └─ main.conversation +│ ├─ conversation.session +│ │ └─ conversation.view +│ │ ├─ conversation.chat.node +│ │ │ ├─ conversation.chat.assistant-actions +│ │ │ ├─ conversation.chat.commandview +│ │ │ ├─ conversation.chat.turnTail +│ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images +│ │ │ └─ tool.view.cordis +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images +│ ├─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner +│ ├─ conversation.composer +│ │ └─ conversation.approval.detail +│ ├─ conversation.composer.bar +│ │ ├─ conversation.input.attachments +│ │ ├─ conversation.input.plan +│ │ └─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.input.dock +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ ├─ conversation.input.right +│ ├─ conversation.hero.brand.mark +│ ├─ conversation.hero.workspace +│ │ └─ conversation.hero.workspace.directoryFlow +│ └─ conversation.hero.agentPreset ├─ rightbar -│ ├─ sidebar.right.pane.tab -│ │ └─ sidebar.right.tab.guide -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item +│ └─ rightbar.session +│ ├─ sidebar.right.pane.tab +│ │ └─ sidebar.right.tab.guide +│ ├─ sidebar.right.pane.tab.title +│ └─ sidebar.right.tab.menu.item └─ shell.overlay ``` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 3c53237bad..4750a413d5 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: cf711185d02808f70a71a46c6d6c1af4da4a7334 -subagent.zh.md: e97b4ab965d279f04ddda5e8ae66621b0198a5cd +subagent.md: 5f927f4c0bef57c0fda0bcb39280a5ac2012343e +subagent.zh.md: ce827f154989404825e322682c4af6ba83182253 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index cf711185d0..5f927f4c0b 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -8,6 +8,8 @@ Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subag Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) +The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. + ## Two kinds of capability, discovered two ways A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a one-shot run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. Those flags describe only the one-shot [`start()`](#the-provider-contract-subagentprovider) path, where the provider composes the child. **Continuable** children are composed by the continuation manager itself, so they are gated by one optional method whose presence IS the capability, with TS narrowing as the discovery mechanism: [`SubagentProvider.prepareContinuable`](#the-provider-contract-subagentprovider). @@ -471,11 +473,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` -Singleton settings owner read by delegation tools when an Agent is published. +Singleton settings owner read when delegation tools are composed for a Session. ```ts cordis-catalog /** - * Read a detached selection preference for the next eligible Agent publication. + * Read a detached selection preference for the next eligible Session composition. * @returns the enabled state and exact allowed routes. */ current(): SubagentModelSelectionSettings @@ -672,6 +674,8 @@ list(): string[] * fulfills; a rejection therefore has no run for the caller to dispose and * emits no run lifecycle events. Post-publication turn and infrastructure * failures settle through the returned run. + * A catalog append failure disposes the run and handles its result rejection; + * the caller receives the catalog error even if disposal also fails. * @param name - the provider to use. * @param request - child label, prompt, parent, signal, and optional capabilities. * @returns the published holder-owned run. diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index e97b4ab965..ce827f1549 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -8,6 +8,8 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) +`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。 + ## 两类能力,两种发现方式 提供方通过一个静态描述符公布其**启动时**功能,服务会在单次 run 存在之前即行检查;如果请求依赖提供方不具备的功能,会被明确拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。这些 flag 仅描述单次 [`start()`](#the-provider-contract-subagentprovider) 路径,即由提供方组合子 agent 的路径。**可继续**子 agent 由继续执行管理器自行组合,因此它们由唯一一个可选方法把关,方法存在即为能力,并以 TypeScript 的类型收窄作为发现机制:[`SubagentProvider.prepareContinuable`](#the-provider-contract-subagentprovider)。 @@ -475,11 +477,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` -Singleton settings owner read by delegation tools when an Agent is published. +Singleton settings owner read when delegation tools are composed for a Session. ```ts cordis-catalog /** - * Read a detached selection preference for the next eligible Agent publication. + * Read a detached selection preference for the next eligible Session composition. * @returns the enabled state and exact allowed routes. */ current(): SubagentModelSelectionSettings @@ -676,6 +678,8 @@ list(): string[] * fulfills; a rejection therefore has no run for the caller to dispose and * emits no run lifecycle events. Post-publication turn and infrastructure * failures settle through the returned run. + * A catalog append failure disposes the run and handles its result rejection; + * the caller receives the catalog error even if disposal also fails. * @param name - the provider to use. * @param request - child label, prompt, parent, signal, and optional capabilities. * @returns the published holder-owned run. diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index c668d876fa..96577399bd 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md -system-prompt.md: 8bb0413ac3bf3559cbc4d35164a9671b905b064c -system-prompt.zh.md: 7b2e9af9c67157ffd1513370fca79019f5ed7d08 +system-prompt.md: 516e3347c06882bfd3ff42638d77a2acb3aca753 +system-prompt.zh.md: 611ea0e9bdd81585efd7106012edd3d17e7bdd8e diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 8bb0413ac3..516e3347c0 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -41,7 +41,7 @@ interface ToolProviderResult { The exported `PERSONA_PREFIX_SECTION` (`deployment:persona-prefix`) and `PERSONA_SUFFIX_SECTION` (`deployment:persona-suffix`) name the slots shared by global configuration and scoped contributions. Their `PromptSectionOrderName` entries are `DEPLOYMENT_PERSONA_PREFIX` and `DEPLOYMENT_PERSONA_SUFFIX`; the [package README](../../packages/core/system-prompt/README.md#configure-the-prompt) owns their placement and template configuration. -`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. Sections sort by ascending order and then code-unit name; repository contributors resolve the service-owned named allocation through `getSectionOrder()`. Runtime-context contributors resolve their independent allocation through `getContextOrder()`. One effective `complete` section becomes the sole prompt section after cooperative assembly. +`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. Sections sort by ascending order and then code-unit name; repository contributors resolve the service-owned named allocation through `getSectionOrder()`. Runtime-context contributors resolve their independent allocation through `getContextOrder()`. One effective `complete` section becomes the sole prompt section after cooperative assembly. agent-loop renders the assembled sections with `renderPrompt` and commits the text as a `system/message` surface node — appended as surface node 0 on the first step, then replaced in place when the rendered text changes or, when the prepared call declares `systemPromptUpdate: 'in-history'`, appended after the cached history for non-empty updates in a continuing series — so the prompt reaches the model as a message of derived history rather than as a request field ([decision](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../../packages/core/agent-loop/README.md#understand-the-implementation)). ```ts type-equiv /** One contributed section of the system prompt (registry input). */ diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index 7b2e9af9c6..611ea0e9bd 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -41,7 +41,7 @@ interface ToolProviderResult { 导出的 `PERSONA_PREFIX_SECTION`(`deployment:persona-prefix`)与 `PERSONA_SUFFIX_SECTION`(`deployment:persona-suffix`)为全局配置和带作用域贡献所共享的段落命名。它们对应的 `PromptSectionOrderName` 项为 `DEPLOYMENT_PERSONA_PREFIX` 与 `DEPLOYMENT_PERSONA_SUFFIX`;[包 README](../../packages/core/system-prompt/README.zh.md#configure-the-prompt)规定其位置与模板配置。 -`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。各段先按 order 升序排列,再按名称的代码单元顺序排列;仓库贡献方通过 `getSectionOrder()` 解析服务持有的具名分配。Runtime-context 贡献方通过 `getContextOrder()` 解析独立分配。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。 +`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。各段先按 order 升序排列,再按名称的代码单元顺序排列;仓库贡献方通过 `getSectionOrder()` 解析服务持有的具名分配。Runtime-context 贡献方通过 `getContextOrder()` 解析独立分配。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。agent loop(智能体循环)用 `renderPrompt` 渲染组装后的各段,并把文本作为 `system/message` surface 节点提交——首个步骤作为 surface 第 0 号节点追加,之后在渲染文本变化时原地替换,或者当已准备调用声明 `systemPromptUpdate: 'in-history'` 时,在序列延续期间把非空更新追加到已缓存历史之后——因此提示词作为派生历史中的消息而不是请求字段到达模型([决策](../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 ```ts type-equiv /** One contributed section of the system prompt (registry input). */ diff --git a/docs/subsystems/token-meter.i18n.yaml b/docs/subsystems/token-meter.i18n.yaml index 14e3c7a5a4..9c36d99ef5 100644 --- a/docs/subsystems/token-meter.i18n.yaml +++ b/docs/subsystems/token-meter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/token-meter.md -token-meter.md: 2265f5073efbd2953e4f399ad58a026772b60006 -token-meter.zh.md: b1d9d66dc804fecace15489e61c1d080c096932d +token-meter.md: 8accb9cf9f0b97ee7684e327da336a31943ff49e +token-meter.zh.md: 6c149ec3f225a7012ca8d8d2312835663917b5d2 diff --git a/docs/subsystems/token-meter.md b/docs/subsystems/token-meter.md index 2265f5073e..8accb9cf9f 100644 --- a/docs/subsystems/token-meter.md +++ b/docs/subsystems/token-meter.md @@ -78,7 +78,8 @@ Replay owner for one service-wide estimator and isolated per-session folds. * usage is reused only when the latest successful call's canonical request * envelope matches `requestHeader` and its total is no lower than that * call's full route-priced anchor; otherwise the complete envelope and - * surface are repriced. + * surface are repriced. The anchor includes all surface nodes immediately + * before the assistant message, including inputs admitted after step/start. * * `requestHeader` replaces the latest logged envelope for pressure and node * pricing; the node set always describes the current session surface. Every diff --git a/docs/subsystems/token-meter.zh.md b/docs/subsystems/token-meter.zh.md index b1d9d66dc8..6c149ec3f2 100644 --- a/docs/subsystems/token-meter.zh.md +++ b/docs/subsystems/token-meter.zh.md @@ -78,7 +78,8 @@ Replay owner for one service-wide estimator and isolated per-session folds. * usage is reused only when the latest successful call's canonical request * envelope matches `requestHeader` and its total is no lower than that * call's full route-priced anchor; otherwise the complete envelope and - * surface are repriced. + * surface are repriced. The anchor includes all surface nodes immediately + * before the assistant message, including inputs admitted after step/start. * * `requestHeader` replaces the latest logged envelope for pressure and node * pricing; the node set always describes the current session surface. Every diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index d448297059..fd980125fb 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 9939e8ab9fff9fa5bd23fd370e07f6296a608824 -tools.zh.md: 52e812a35d6932a5ed00a86a3d3fe71460b26cd4 +tools.md: 2a57d21d628935ff08bcb6032e5750f031f7c020 +tools.zh.md: 5d29824969b01a04356dfc67832ca20a6790a1dc diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 9939e8ab9f..2a57d21d62 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -262,14 +262,14 @@ PTC mode's bridge additionally exposes each settled sub-dispatch to the `tools/p * copy a listener may reshape. `content` is the RENDERED result projection * (what a native `tool/result` would carry) — the program itself received * the structured `value` (or just the error message on failure); only the - * `tool/code-dispatch` event's copy changes. + * `tool/ptc-dispatch` event's copy changes. */ interface PtcDispatchLog { /** The outer `run_code` execution. */ readonly exec: ToolExecution /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ readonly agent?: Agent - /** Deterministic sub-call id (`:code:`). */ + /** Opaque sub-call id; new calls use `:ptc:`. */ readonly subCallId: ToolCallId /** The dispatched sub-tool name. */ readonly name: string @@ -367,7 +367,7 @@ interface ToolExecutionFailure { type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure ``` -The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. The canonical `value` is execution-local: the loop persists only `content`, `error`, and `meta`, while `tool/code-dispatch` stores the sub-call's rendered `content` and `isError` verbatim. Replay reproduces presentation but cannot reconstruct canonical intermediate values. +The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. The canonical `value` is execution-local: the loop persists only `content`, `error`, and `meta`, while `tool/ptc-dispatch` stores the sub-call's rendered `content` and `isError` verbatim. Replay reproduces presentation but cannot reconstruct canonical intermediate values. On success the registry snapshots and validates the body value, freezes it, and invokes the pure renderer plus the optional top-level-call metadata projector. It separately materializes the durable presentation fields immediately before `tools/result`; an invalid value, renderer/projector failure, or non-JSON presentation becomes a JSON-safe `isError`. The final live observer therefore sees the exact execution-local value beside fields safe for the later durable append. @@ -674,13 +674,13 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index #### `tools/ptc-dispatch-log` — waterfall -Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/ptc-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. ```ts cordis-catalog /** * Allow a listener to replace content in the DURABLE LOG COPY of one * `run_code` sub-dispatch outcome before the bridge appends its - * `tool/code-dispatch` event. `next()` keeps the + * `tool/ptc-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 52e812a35d..5d29824969 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -262,14 +262,14 @@ PTC mode 的桥接层还会把每个已结算的子分派暴露给 `tools/ptc-di * copy a listener may reshape. `content` is the RENDERED result projection * (what a native `tool/result` would carry) — the program itself received * the structured `value` (or just the error message on failure); only the - * `tool/code-dispatch` event's copy changes. + * `tool/ptc-dispatch` event's copy changes. */ interface PtcDispatchLog { /** The outer `run_code` execution. */ readonly exec: ToolExecution /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ readonly agent?: Agent - /** Deterministic sub-call id (`:code:`). */ + /** Opaque sub-call id; new calls use `:ptc:`. */ readonly subCallId: ToolCallId /** The dispatched sub-tool name. */ readonly name: string @@ -367,7 +367,7 @@ interface ToolExecutionFailure { type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure ``` -结果仅承载产出。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果经过每个钩子,并出现在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。规范的 `value` 仅存在于执行期间:循环只持久化 `content`、`error` 和 `meta`,`tool/code-dispatch` 则原样存储子调用渲染后的 `content` 与 `isError`。回放可以重现展示,却无法重建规范的中间值。 +结果仅承载产出。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果经过每个钩子,并出现在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。规范的 `value` 仅存在于执行期间:循环只持久化 `content`、`error` 和 `meta`,`tool/ptc-dispatch` 则原样存储子调用渲染后的 `content` 与 `isError`。回放可以重现展示,却无法重建规范的中间值。 成功时,注册表会快照并校验函数体返回值,将其冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。注册表会在 `tools/result` 之前另行物化持久展示字段;无效值、渲染器/投影器失败或非 JSON 展示都会转为 JSON 安全的 `isError`。因此,最终实时观察者能看到精确的执行期值,以及可安全用于后续持久追加的字段。 @@ -674,13 +674,13 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index #### `tools/ptc-dispatch-log` — waterfall -Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/ptc-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. ```ts cordis-catalog /** * Allow a listener to replace content in the DURABLE LOG COPY of one * `run_code` sub-dispatch outcome before the bridge appends its - * `tool/code-dispatch` event. `next()` keeps the + * `tool/ptc-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 77fbb01e39..154640d962 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 3e54ef3d0a2339d50fbc8a4132b91d7f82701321 -workspace.zh.md: a9b8375f388538a3588a84f2d71e47b89ddc679a +workspace.md: a7c4b8843f0a0b2d2fb251157f7928a40df1e405 +workspace.zh.md: 3474cbf147848b04a4ec51147983a343bcc1e91d diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 3e54ef3d0a..a7c4b8843f 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -246,13 +246,13 @@ Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/wo ### `ctx.workspaceFiles` — `WorkspaceFiles` -Host Remote service over the composed filesystem, confined to one workspace. +Host Remote file reads and workspace directory observations over the composed filesystem. ```ts cordis-catalog /** - * Read one page of lines from a UTF-8 text file inside the Agent's workspace. + * Read one page of lines from a UTF-8 file readable by the filesystem backend. * @param agent - target Agent resolved from the Session identity on the wire. - * @param path - workspace path, absolute or relative to the workspace root. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the line window; omitted fields take the page defaults. * @param signal - caller cancellation. * @returns the page, the file's version at the stat before it, and whether it reaches the last line. @@ -260,20 +260,39 @@ Host Remote service over the composed filesystem, confined to one workspace. @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise /** - * Read one byte window of a regular file inside the Agent's workspace: raw + * Read one byte window of a regular file readable by the filesystem backend: raw * bytes, no text decoding and no binary rejection. * @param agent - target Agent resolved from the Session identity on the wire. - * @param path - workspace path, absolute or relative to the workspace root. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the byte window; omitted fields take the window defaults. * @param signal - caller cancellation. * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. */ @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +/** + * Read a complete regular file as bytes, subject to the configured full-file cap. + * @param agent - target Agent whose workspace resolves relative paths. + * @param path - absolute or workspace-relative file path. + * @param signal - caller cancellation. + * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. + */ +@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise + +/** + * Read a complete file relative to another file's directory, including outside the workspace. + * @param agent - Agent whose workspace resolves the base file's relative path. + * @param path - base file, absolute or workspace-relative. + * @param relativePath - relative filesystem path, not a URL or absolute path. + * @param signal - caller cancellation. + * @returns the complete related file using the ordinary file-size and access checks. + */ +@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise + /** * Report one regular file's identity, version, and size without its content. * @param agent - target Agent resolved from the Session identity on the wire. - * @param path - workspace path, absolute or relative to the workspace root. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param signal - caller cancellation. * @returns the file's absolute path, current version, and byte size. */ diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index a9b8375f38..3474cbf147 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -246,13 +246,13 @@ Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/wo ### `ctx.workspaceFiles` — `WorkspaceFiles` -Host Remote service over the composed filesystem, confined to one workspace. +Host Remote file reads and workspace directory observations over the composed filesystem. ```ts cordis-catalog /** - * Read one page of lines from a UTF-8 text file inside the Agent's workspace. + * Read one page of lines from a UTF-8 file readable by the filesystem backend. * @param agent - target Agent resolved from the Session identity on the wire. - * @param path - workspace path, absolute or relative to the workspace root. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the line window; omitted fields take the page defaults. * @param signal - caller cancellation. * @returns the page, the file's version at the stat before it, and whether it reaches the last line. @@ -260,20 +260,39 @@ Host Remote service over the composed filesystem, confined to one workspace. @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise /** - * Read one byte window of a regular file inside the Agent's workspace: raw + * Read one byte window of a regular file readable by the filesystem backend: raw * bytes, no text decoding and no binary rejection. * @param agent - target Agent resolved from the Session identity on the wire. - * @param path - workspace path, absolute or relative to the workspace root. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the byte window; omitted fields take the window defaults. * @param signal - caller cancellation. * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. */ @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +/** + * Read a complete regular file as bytes, subject to the configured full-file cap. + * @param agent - target Agent whose workspace resolves relative paths. + * @param path - absolute or workspace-relative file path. + * @param signal - caller cancellation. + * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. + */ +@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise + +/** + * Read a complete file relative to another file's directory, including outside the workspace. + * @param agent - Agent whose workspace resolves the base file's relative path. + * @param path - base file, absolute or workspace-relative. + * @param relativePath - relative filesystem path, not a URL or absolute path. + * @param signal - caller cancellation. + * @returns the complete related file using the ordinary file-size and access checks. + */ +@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise + /** * Report one regular file's identity, version, and size without its content. * @param agent - target Agent resolved from the Session identity on the wire. - * @param path - workspace path, absolute or relative to the workspace root. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param signal - caller cancellation. * @returns the file's absolute path, current version, and byte size. */ diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 07c65f5661..899424ae54 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: 169430c8905d4adee611e2c9947732ffd602481a -testing.zh.md: 8bb3975b3ad07365284a72850e089714fb601566 +testing.md: b9800bd7aa25e2556d2fa97e9397c140fffdb442 +testing.zh.md: 59f1a7ca05f0e50f6a3999498b41670228618514 diff --git a/docs/testing.md b/docs/testing.md index 169430c890..b9800bd7aa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,7 +14,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff. - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares session-driven output under `snapshots/web/` and UI-only output under `apps/web/tests/expected/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` builds first for plugin CSS. -Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current v2 uses `.v2`, one row per event, and embedded compact Assistant streams; retained v0 (suffixless) and v1 (`.v1`) may keep canonical packed rows for migration coverage. [The migrator](../scripts/migrate-packed-session-fixtures.ts) rewrites older historical layouts. +Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current V3 uses `.v3`, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors. ## How specs execute diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 8bb3975b3a..59f1a7ca05 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -14,7 +14,7 @@ - **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 比较 `snapshots/web/` 下由会话驱动的输出,以及 `apps/web/tests/expected/` 下仅含 UI 的输出。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。`test:web` 会先构建以交付插件 CSS。 -Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 v2 使用 `.v2`、每个事件一行,并嵌入紧凑 Assistant stream;保留的 v0(无后缀)与 v1(`.v1`)可以为迁移覆盖保留规范 packed row。[迁移器](../scripts/migrate-packed-session-fixtures.ts)会改写更旧的历史布局。 +Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 V3 使用 `.v3`、每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。 ## spec 如何被执行 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 4bb43418d9..49f6610bd2 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 1a98079dcf3d30696cf2dae2895372a90377204d -tool-catalog.zh.md: 41b5f6d4bc8b0e666e74dde5b4bb43bd0064da8e +tool-catalog.md: c2ea95ff460b65fbba789738b16b0c67a7c91948 +tool-catalog.zh.md: 41fc1eaa3c6a0acf17bde4b69c97413b64872930 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1a98079dcf..c2ea95ff46 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -16,9 +16,10 @@ This table connects model-visible tool names to the plugin package and service s | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`, `cordis_inspect_list`, `cordis_inspect_query`, `cordis_inspect_self`, `cordis_run`, `cordis_stop`, `cordis_undefine` | `ctx.tools`, `ctx.dynamicCordisRunner` | `tool/call`, `tool/result`, `process-local dynamic package lifecycle` | - | Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | @@ -218,6 +219,49 @@ Source: [`packages/shell/tool-bash/src/index.ts`](../packages/shell/tool-bash/sr The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. + + +## `@deepseek-ai/dsh-tool-present` + +### `present` + +Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool. + +```json +{ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Path of an existing file inside the workspace." + }, + "description": { + "type": "string", + "description": "Brief description for the user." + } + }, + "required": [ + "path" + ] + } + } + }, + "required": [ + "files" + ] +} +``` + +Source: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts) + +Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. + ## `@deepseek-ai/dsh-tool-pwsh` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 41b5f6d4bc..41fc1eaa3c 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -20,9 +20,10 @@ | 工具包 | 模型可见名称 | 依赖 | 写入/影响 | 随产品发布的别名 | 部署说明 | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`、`cordis_inspect_list`、`cordis_inspect_query`、`cordis_inspect_self`、`cordis_run`、`cordis_stop`、`cordis_undefine` | `ctx.tools`、`ctx.dynamicCordisRunner` | `tool/call`、`tool/result`、`process-local dynamic package lifecycle` | - | 不在任何随产品发布的树中,需要显式选择启用;动态 Package 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。该工具集注入 `@deepseek-ai/dsh-cordis-host-runner` 提供的 `ctx.dynamicCordisRunner`,后者拥有定义注册表和 vm 沙箱;组合缺少它时这些工具不会激活。运行中的 Package 在停止、undefine 或 DSH 重启前可以注册**额外的**模型可见工具;发生这类工具集变化时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | @@ -222,6 +223,49 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类 bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 + + +## `@deepseek-ai/dsh-tool-present` + +### `present` + +声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。 + +```json +{ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Path of an existing file inside the workspace." + }, + "description": { + "type": "string", + "description": "Brief description for the user." + } + }, + "required": [ + "path" + ] + } + } + }, + "required": [ + "files" + ] +} +``` + +来源: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts) + +交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 + ## `@deepseek-ai/dsh-tool-pwsh` diff --git a/docs/tool-execution-pipeline.i18n.yaml b/docs/tool-execution-pipeline.i18n.yaml index 6ff5ef0a15..5db82c4fc1 100644 --- a/docs/tool-execution-pipeline.i18n.yaml +++ b/docs/tool-execution-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 docs/tool-execution-pipeline.md -tool-execution-pipeline.md: a799c68a60f6782ef3bb79c52f89cbc78d762ab3 -tool-execution-pipeline.zh.md: 7ffdd34298f630c975b23f23daf875a3c4cdc84d +tool-execution-pipeline.md: f9d3d145bb3d7c271882942b4adc100f53a55290 +tool-execution-pipeline.zh.md: 3bfa6f358ee8482feaa9d26541e495b86a02e515 diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index a799c68a60..f9d3d145bb 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -17,7 +17,7 @@ flowchart TD around["tools/execute waterfall
timeout, retry, metrics (around dispatch)"] toolBody["Registered tool execute() body"] fsGate["fs/write-intent or fs/edit-intent
tool-fs mutations only"] - owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] + owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/ptc-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] normalized["Registry outer normalization
pipeline/result snapshot throws become isError"] finalize["ToolDefinition.finalizeContent
last content-only invariant"] @@ -57,6 +57,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. +Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/docs/tool-execution-pipeline.zh.md b/docs/tool-execution-pipeline.zh.md index 7ffdd34298..3bfa6f358e 100644 --- a/docs/tool-execution-pipeline.zh.md +++ b/docs/tool-execution-pipeline.zh.md @@ -19,7 +19,7 @@ flowchart TD around["tools/execute waterfall
timeout, retry, metrics (around dispatch)"] toolBody["Registered tool execute() body"] fsGate["fs/write-intent or fs/edit-intent
tool-fs mutations only"] - owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] + owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/ptc-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] normalized["Registry outer normalization
pipeline/result snapshot throws become isError"] finalize["ToolDefinition.finalizeContent
last content-only invariant"] @@ -59,6 +59,6 @@ flowchart TD allResults --> context ``` -文件系统的先读后编辑检查位于 `tool-fs` 之下,通过 `fs/*` 事件实现。通用的前置/后置 waterfall 承载钩子与审批策略;`ctx.approval` 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册的守卫。超时等环绕分发关注点对 `tools/execute` 进行包装。注册表会对候选结果进行无损快照;如果快照失败,则会先将失败规范化,之后再由可见定义中已随快照固定的 `finalizeContent` 回调强制执行其同步且仅限内容的不变式。随后,`tools/result` 会观察不可变、可由 JSON 无损表示的结果。这样一来,钩子便可跨越不同工具系列,而无需让工具与某个策略服务耦合。PTC mode 会将保留的 `run_code` 传输及其序列化子调用都送入流水线;子调用携带父级 token、记录 `tool/code-dispatch`、将拒绝呈现为具有约束力的驳回,并省略 `additionalContexts`,以保持调用与结果相邻。 +文件系统的先读后编辑检查位于 `tool-fs` 之下,通过 `fs/*` 事件实现。通用的前置/后置 waterfall 承载钩子与审批策略;`ctx.approval` 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册的守卫。超时等环绕分发关注点对 `tools/execute` 进行包装。注册表会对候选结果进行无损快照;如果快照失败,则会先将失败规范化,之后再由可见定义中已随快照固定的 `finalizeContent` 回调强制执行其同步且仅限内容的不变式。随后,`tools/result` 会观察不可变、可由 JSON 无损表示的结果。这样一来,钩子便可跨越不同工具系列,而无需让工具与某个策略服务耦合。PTC mode 会将保留的 `run_code` 传输及其序列化子调用都送入流水线;子调用携带父级 token、记录 `tool/ptc-dispatch`、将拒绝呈现为具有约束力的驳回,并省略 `additionalContexts`,以保持调用与结果相邻。 维护模式:英文源文件包含人工维护的 Mermaid 流程图,并由生成器写出;本中文文件作为经评审对侧通过双语配对维护。确切的工具 schema 与事件签名位于生成的目录中。 diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 04933034ac..476fb26576 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md -python-sdk.md: 88cf32f60c285a4ce7c09e424532a6d9b7b00890 -python-sdk.zh.md: e443ec811a3b42034c75162b759408f1acd3486f +python-sdk.md: 6fde258d9f4f3e8ba1fd41537129ba5b8f02fd58 +python-sdk.zh.md: f50fd6d2bd37e3dc203b519e07d0df4098cab0c7 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 88cf32f60c..6fde258d9f 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -129,19 +129,35 @@ The first command initializes the shipped standalone profile. The second forward Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition. + +### Opt in to `str_replace_editor` + +The bundled runtime includes `str_replace_editor`, but `sdk-minimal` omits it from the default Cordis tree. To use it, save this configuration as `editor.patch.yml`; `insert` adds both the editor and the filesystem provider that the minimal profile lacks: + +```yaml +- insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' +``` + +Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSeekHarness(profile="sdk-minimal", ...)`, or put the patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent configuration. On the next runtime launch, model requests include `str_replace_editor` beside the persistent shell. The local filesystem provider uses the runtime working directory for relative paths; like the minimal shell, it does not confine access to that directory. For the standard `sdk` profile, insert only the editor row so it uses the existing filesystem provider and policies. + ## Understand the minimal profile | Property | Value | |---|---| | System prompt | `DSH_SYSTEM_PROMPT`, falling back to `You are a helpful software engineer assistant.` | | Model in `minimal.py` | `--model`, then `DSH_MODEL`, then `deepseek-v4-flash` | -| Model-facing tools | Persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor` | +| Model-facing tool | Persistent `bash` on Linux/macOS or `pwsh` on Windows | | Shell timeout | 300 seconds | -| Editor output limit | 16,000 characters | | Runtime context and compaction | Absent | | Session persistence | Uncompressed JSONL under `/sessions` | -The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so the platform-selected persistent shell and editor can modify any path visible to the runtime; use a disposable checkout or container. +The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while filesystem tools, settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container. The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index e443ec811a..f50fd6d2bd 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -129,19 +129,35 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle 另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。 + +### 显式启用 `str_replace_editor` + +随附运行时包含 `str_replace_editor`,但 `sdk-minimal` 的默认 Cordis tree 不挂载它。要使用该工具,请将以下配置保存为 `editor.patch.yml`;`insert` 会添加 editor,以及极简 profile 缺少的文件系统后端: + +```yaml +- insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' +``` + +构造 `DeepSeekHarness(profile="sdk-minimal", ...)` 时传入 `patches=("/absolute/path/to/editor.patch.yml",)`,或将 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` 以持久保存配置。下次运行时启动后,模型请求会在持久 shell 之外包含 `str_replace_editor`。本地文件系统后端以运行时工作目录解析相对路径;与极简 shell 一样,它不会将访问限制在该目录内。对于标准 `sdk` profile,只插入 editor 配置项,让它使用已有的文件系统后端与策略。 + ## 理解极简 profile | 属性 | 值 | |---|---| | 系统提示词 | `DSH_SYSTEM_PROMPT`,未设置时为 `You are a helpful software engineer assistant.` | | `minimal.py` 的模型 | `--model`,然后是 `DSH_MODEL`,最后是 `deepseek-v4-flash` | -| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh`,以及 `str_replace_editor` | +| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh` | | Shell 超时 | 300 秒 | -| Editor 输出上限 | 16,000 字符 | | 运行时上下文与 compaction | 不存在 | | 会话持久化 | `/sessions` 下的未压缩 JSONL | -该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 与 editor 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 +该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文件系统工具、settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。 diff --git a/lefthook.yml b/lefthook.yml index 1c2a693c88..237202e7e0 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,7 +28,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/native/README.i18n.yaml b/native/README.i18n.yaml index 0d7eebd170..74db687acb 100644 --- a/native/README.i18n.yaml +++ b/native/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 native/README.md -README.md: 51c8da7b57df15e65b8e431ee18ce6cebb89d54b -README.zh.md: 6d07c6afb43e2763795a71639707454b9cfbe280 +README.md: 100e25dbfa9f472cb6a5a93a719eb7e1be02d10e +README.zh.md: b1a66ac678334fd535951a7a0e4a921622b8ba51 diff --git a/native/README.md b/native/README.md index 51c8da7b57..100e25dbfa 100644 --- a/native/README.md +++ b/native/README.md @@ -2,10 +2,10 @@ English | [中文](README.zh.md) -Native source and public packages maintained with DeepSeek Harness. The [`landlock-run/` workspace](landlock-run/README.md) owns the Landlock self-restrict-then-exec launcher consumed by the harness, including its architecture, three-package npm family, platform support, development workflow, and [release procedure](landlock-run/docs/release.md). +Native source and public packages maintained with DeepSeek Harness. The [`system/` workspace](system/README.md) owns the Landlock launcher and POSIX flock binding, their platform packages, and the [release procedure](system/docs/release.md). ## Workspace and release boundary -`landlock-run/` and its packages belong to the repository's root pnpm workspace and lockfile. Harness consumers use the current workspace entry package during development and CI, so a launcher contract change and its consumer update can land and be tested together. +`system/` and its packages belong to the repository's root pnpm workspace and lockfile. Harness consumers use the current workspace entry package during development and CI, so a launcher contract change and its consumer update can land and be tested together. -The main repository's `Landlock Run` workflow builds and tests each supported architecture. `Landlock Run Release` assembles those native artifacts, packs and verifies the three npm tarballs, then optionally publishes them under one launcher version. The entry package retains platform packages as npm optional dependencies, so npm still installs only the package matching the user's operating system and CPU. +The main repository's `Node Addon System` workflow builds and tests each supported architecture. `Node Addon System Release` assembles those native artifacts, packs and verifies the npm tarballs, then optionally publishes them under one native version. The entry package retains platform packages as npm optional dependencies, so npm still installs only the package matching the user's operating system and CPU. diff --git a/native/README.zh.md b/native/README.zh.md index 6d07c6afb4..b1a66ac678 100644 --- a/native/README.zh.md +++ b/native/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -与 DeepSeek Harness 一同维护的原生源码和公开包。[`landlock-run/` workspace](landlock-run/README.zh.md) 负责 harness 使用的 Landlock 自限后执行启动器,包括其架构、由三个包组成的 npm 包家族、平台支持、开发工作流和[发布流程](landlock-run/docs/release.md)。 +与 DeepSeek Harness 一同维护的原生源码和公开包。[`system/` workspace](system/README.zh.md) 负责 Landlock 启动器、POSIX flock 绑定、平台包和[发布流程](system/docs/release.md)。 ## Workspace 与发布边界 -`landlock-run/` 及其包属于仓库根 pnpm workspace,并共用根锁文件。开发和 CI 中的 harness 消费方直接使用当前 workspace 的入口包,因此启动器约定变更与消费方更新可以在同一个改动中落地并一起测试。 +`system/` 及其包属于仓库根 pnpm workspace,并共用根锁文件。开发和 CI 中的 harness 消费方直接使用当前 workspace 的入口包,因此启动器约定变更与消费方更新可以在同一个改动中落地并一起测试。 -主仓库的 `Landlock Run` 工作流为每个受支持架构构建并测试。`Landlock Run Release` 汇集这些原生产物,打包并验证三个 npm tarball,随后可选择以同一个启动器版本发布。入口包继续将平台包声明为 npm 可选依赖,因此 npm 仍然只会安装与用户操作系统和 CPU 匹配的包。 +主仓库的 `Node Addon System` 工作流为每个受支持架构构建并测试。`Node Addon System Release` 汇集这些原生产物,打包并验证 npm tarball,随后可选择以同一个原生包版本发布。入口包继续将平台包声明为 npm 可选依赖,因此 npm 仍然只会安装与用户操作系统和 CPU 匹配的包。 diff --git a/native/landlock-run/AGENTS.md b/native/landlock-run/AGENTS.md deleted file mode 100644 index 2b03ddcec7..0000000000 --- a/native/landlock-run/AGENTS.md +++ /dev/null @@ -1,50 +0,0 @@ -# AGENTS.md - -This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and implements its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. - -## Pre-release stance - -The project is pre-1.0. Prefer the correct public API over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them. - -## Runtime safety rules - -- Every tool must fail closed. If a ruleset cannot be created or the kernel does not enforce it, exit non-zero WITHOUT exec'ing the wrapped command. Never run unconfined as a fallback. -- Runtime binaries and the entry packages take NO environment-variable overrides: which binary confines a process must never be decidable by the ambient environment. Test injection is by function parameter; the `NALR_*` prefix is for build/test orchestration only. -- Kernel UAPI is self-defined in the C source (verbatim from the kernel headers), keeping builds independent of toolchain header vintage and making the definitions part of the audit record. -- No libraries beyond libc, linked statically against musl. The audit surface of a tool is its C source plus the kernel's stable syscall contract. -- The CLI contract of each tool ([docs/cli-contract.md](docs/cli-contract.md)) is the cross-repo compatibility contract: argv grammar, exit codes, and report lines change only with a version bump and a changelog entry, and consumers parse them only through the entry package. -- There is deliberately NO install-time build fallback: a host without a matching platform package gets a nonexistent launcher path, the consumer's probe fails, and the consumer falls closed — that degradation is part of the design, not a gap to fill with node-gyp. - -## Repository layout - -```text -packages/entry/ Published entry package: JavaScript API (resolve/probe/grants) + the C source. -packages/linux-*/ Published per-platform packages: one prebuilt static binary, no JavaScript. -scripts/ Build, matrix derivation, prepack gates, and release orchestration. -test/ Plain-node behavioral tests (entry API + real-kernel launcher proofs). -docs/ Architecture, packaging, CLI contract, release, support matrix, naming. -``` - -## Commands - -```sh -pnpm install -pnpm build:ts # entry packages → lib/ -pnpm build:native # this Linux architecture's binaries (needs musl-tools); fails fast elsewhere -pnpm typecheck -pnpm test # entry tests everywhere; launcher tests need linux + built binary -``` - -## Packaging invariants - -- The package matrix is explicit, checked-in metadata: `packages//package.json` (`os`, `cpu`), `packages//prebuilds.json` (the binaries that may exist there), and [docs/support-matrix.md](docs/support-matrix.md) stay synchronized when the matrix changes. `scripts/github-matrix.mjs` derives CI and release matrices from it; nothing else enumerates platforms. -- Platform package names contain platform only (`-linux-x64`), never tool variants — those stay inside `prebuilds.json`. Static musl linking is why there is no libc suffix: one binary serves glibc and musl distros. -- Platform packages ship no JavaScript; the entry package resolves them to file paths. Backends prove themselves at runtime through the functional probe, never through metadata trust. -- Builds are native-only: each architecture compiles its own binary on its own runner (CI is the builder of record); no cross toolchain enters the repo. -- Every tarball is gated at pack time: platform packages refuse to pack without their declared binaries present, executable, and in the right ELF architecture (`verify-launcher-binary.mjs`), entry packages without built `lib/` (`verify-entry-lib.mjs`), and the release pipeline byte-pins installed binaries against the workspace builds (`verify-packed-install.mjs`). -- Platform tarballs are packed with `npm pack`, never `pnpm pack`: pnpm's pack path strips the executable bit (observed on 11.7.0), shipping a launcher no consumer can spawn. `pack-release.mjs` encodes the split; the rehearsal asserts executability of the installed copy so a regression fails loudly instead of masquerading as a non-enforcing kernel. -- Generated artifacts stay out of git: `packages/*/bin/`, `packages/*/lib/`, `dist/`, `.release/`, `*.tsbuildinfo`. Ignore rules live in the ROOT `.gitignore` only — a package-nested ignore file can silently drop payload from tarballs. - -## Documentation - -User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implementation belongs in [docs/architecture.md](docs/architecture.md). diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md deleted file mode 100644 index bf9b163d42..0000000000 --- a/native/landlock-run/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# @deepseek-ai/node-addon-landlock-run - -English | [中文](README.zh.md) - -A [Landlock](https://landlock.io/) self-restrict-then-exec launcher for confining subprocesses on Linux, distributed as prebuilt per-platform npm packages plus a thin JS entry package that resolves the binary and speaks its CLI contract. Built for agent harnesses and other hosts that need to run untrusted commands under a filesystem allow-list without confining themselves. - -The tool is **`landlock-run`** — a self-restrict-then-exec [Landlock](https://landlock.io/) launcher (~300 lines of C11 over the raw kernel UAPI, statically linked against musl). It installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the command and every process it spawns run confined while the invoking process stays unrestricted. Fail-closed: if the kernel cannot enforce, it exits without running the command. - -## Install - -```sh -npm install @deepseek-ai/node-addon-landlock-run -``` - -Published packages use an entry package plus platform optional packages: - -```text -@deepseek-ai/node-addon-landlock-run -@deepseek-ai/node-addon-landlock-run-linux-x64 -@deepseek-ai/node-addon-landlock-run-linux-arm64 -``` - -npm's `os`/`cpu` fields make installers fetch only the matching platform package. There is no install-time build fallback on purpose: on a host without a platform package the resolved path never exists, the probe reports `unusable`, and the consumer falls closed. - -## Usage - -```js -import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; - -const launcher = launcherPath(); -if (probe(launcher) !== 'unusable') { - const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; - // spawn argv with your process runner of choice -} -``` - -The public API is intentionally small: - -- `launcherPath()`: absolute path of this host's launcher (existence deliberately unchecked — the probe is the availability signal). -- `probe(launcher?, { timeoutMs? })`: functional enforcement probe — `'full' | 'partial' | 'unusable'`. -- `grantArgs({ readOnly?, readWrite? })`: the launcher's grant argv; everything not granted is denied. -- `LAUNCHER_BIN` and `LAUNCHER_FAILURE_EXIT` (125): contract constants. A successfully exec'd child may also return 125, so consumers need the fatal diagnostic as well as the status to attribute launcher failure. - -The full binary contract (argv grammar, exit codes, report lines) is pinned in [docs/cli-contract.md](docs/cli-contract.md). - -## Support - -linux-x64 and linux-arm64, kernel with Landlock enabled (5.13+; ABI level determines `full` vs `partial` enforcement — see [docs/support-matrix.md](docs/support-matrix.md)). Other platforms deliberately have no package: consumers run different confinement backends there. - -## Development - -```sh -corepack enable -pnpm install -pnpm build:ts # entry packages → lib/ -pnpm build:native # this Linux architecture's binaries (apt-get install musl-tools) -pnpm test -``` - -Binaries are git-ignored and built natively per architecture — locally for your own machine, by CI's per-arch runners as the builders of record. Release flow: [docs/release.md](docs/release.md). diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md deleted file mode 100644 index 7bd4765a73..0000000000 --- a/native/landlock-run/README.zh.md +++ /dev/null @@ -1,60 +0,0 @@ -# @deepseek-ai/node-addon-landlock-run - -[English](README.md) | 中文 - -一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以按平台预构建的 npm 包以及一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)约定。该启动器面向需要让不可信命令在文件系统允许清单约束下运行、同时保持自身不受限制的 agent harness(智能体框架)和其他宿主。 - -该工具是 **`landlock-run`**:一个「先限制自身、再执行」的 [Landlock](https://landlock.io/) 启动器(基于原始内核 UAPI 编写,约 300 行 C11,并与 musl 静态链接)。它在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此命令及其产生的每个进程都在限制下运行,调用进程仍不受限制。它采用失败闭合:如果内核无法强制执行,则不运行命令并直接退出。 - -## 安装 - -```sh -npm install @deepseek-ai/node-addon-landlock-run -``` - -已发布包由一个入口包和可选平台包组成: - -```text -@deepseek-ai/node-addon-landlock-run -@deepseek-ai/node-addon-landlock-run-linux-x64 -@deepseek-ai/node-addon-landlock-run-linux-arm64 -``` - -npm 的 `os`/`cpu` 字段使安装器只拉取匹配的平台包。系统有意不提供安装时构建回退:在没有对应平台包的宿主上,解析后的路径绝不存在,探测会报告 `unusable`,消费方以失败闭合方式处理。 - -## 用法 - -```js -import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; - -const launcher = launcherPath(); -if (probe(launcher) !== 'unusable') { - const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; - // spawn argv with your process runner of choice -} -``` - -公开 API 有意保持精简: - -- `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。 -- `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`。 -- `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。 -- `LAUNCHER_BIN` 和 `LAUNCHER_FAILURE_EXIT`(125):约定常量。成功完成 exec 的子进程也可能返回 125,因此消费方必须同时看到致命诊断和该状态,才能将结果归因为启动器失败。 - -完整的二进制约定(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。 - -## 支持范围 - -支持 linux-x64 和 linux-arm64,且内核已启用 Landlock(5.13+;ABI 级别决定强制执行为 `full` 还是 `partial`,详见 [docs/support-matrix.md](docs/support-matrix.md))。其他平台有意不提供对应包:消费方会在这些平台上运行其他限制后端。 - -## 开发 - -```sh -corepack enable -pnpm install -pnpm build:ts # entry packages → lib/ -pnpm build:native # this Linux architecture's binaries (apt-get install musl-tools) -pnpm test -``` - -二进制文件被 git 忽略,并且按架构原生构建:本地只构建当前机器的版本,CI 各架构 runner 产出的构建则作为正式发布依据。发布流程详见 [docs/release.md](docs/release.md)。 diff --git a/native/landlock-run/docs/architecture.md b/native/landlock-run/docs/architecture.md deleted file mode 100644 index 72d2f85cff..0000000000 --- a/native/landlock-run/docs/architecture.md +++ /dev/null @@ -1,34 +0,0 @@ -# Architecture - -This repository owns confinement *mechanism*, not policy: consumers (agent harnesses and sandbox capabilities) decide which paths a run may read or write; this package family provides the launcher that enforces those grants and the JavaScript API that resolves and speaks to it. The packaging follows the per-platform-package model of [`node-addon-require-builtin`](https://www.npmjs.com/package/@esplus/node-addon-require-builtin) (and esbuild), adapted from Node addons to standalone static executables. - -## Two-layer package family - -The family is one entry package plus per-platform binary packages: - -- **Entry package** (`@deepseek-ai/node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. -- **Platform packages** (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. - -Because the CLI parser and binary are versioned together in one package family, the parser cannot fall behind that binary version. Preventing that mismatch is why the package split exists. - -There is no shared loader package: platform packages have nothing to load. If a second tool ever needs shared JS, extract it then, not preemptively. - -## Resolution and availability - -`launcherPath()` resolves `@deepseek-ai/node-addon-landlock-run--` and returns `/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two. - -The probe is functional — the launcher builds and enforces a real maximal ruleset in a short-lived child — because version checks would miss a kernel that has the syscalls but refuses enforcement. - -## Fail-closed everywhere - -The launcher exits `125` without exec'ing the command on any launcher-level failure: usage error, unenforcing kernel, unopenable grant root, failed exec. Partial enforcement (an older Landlock ABI governing only a subset of accesses) is accepted, reported on stderr, and surfaced by the probe as `partial` — the consumer decides what its mode vocabulary promises at each level. Neither the binary nor the entry package reads environment variables: which binary confines a process is never decidable by the ambient environment. - -## Build and release model - -Builds are native-only. `scripts/build.ts` compiles the running architecture's binaries with the distro `musl-gcc` (static: no loader or libc expectations on consumers, one binary for glibc and musl distros); CI's per-architecture runners are the builders of record, and no cross toolchain exists in the repo. Review covers the C source and the CI job that built each binary, enforced by three gates: platform prepack refuses missing/wrong-ELF binaries, entry prepack refuses unbuilt `lib/`, and the release pipeline byte-pins installed binaries against the workspace builds they were packed from. - -The package matrix is checked-in metadata (`prebuilds.json` + `os`/`cpu` fields); `scripts/github-matrix.mjs` derives the CI and Release matrices from it, so adding a platform extends automation without editing workflows. - -## Adding a platform - -A new platform adds one `packages//` package (`package.json` with `os`/`cpu`, `prebuilds.json`, README, LICENSE), a runner entry in `scripts/github-matrix.mjs`, and a row in [support-matrix.md](support-matrix.md) — added only together with a native GitHub runner that builds and proves it (the no-cross-toolchain rule). Sibling launchers for other confinement mechanisms belong in their own repositories on this same template, not as second tools here. diff --git a/native/landlock-run/docs/packaging.md b/native/landlock-run/docs/packaging.md deleted file mode 100644 index 16ee74de97..0000000000 --- a/native/landlock-run/docs/packaging.md +++ /dev/null @@ -1,45 +0,0 @@ -# Packaging - -The package family uses the same layout as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend division — each platform package carries exactly the static executables its `prebuilds.json` declares. - -## Published packages - -```text -@deepseek-ai/node-addon-landlock-run -@deepseek-ai/node-addon-landlock-run-linux-x64 -@deepseek-ai/node-addon-landlock-run-linux-arm64 -``` - -Unsupported platforms are intentionally absent from `optionalDependencies` — see [support-matrix.md](support-matrix.md). - -## Package matrix - -The matrix is explicit in checked-in metadata: - -- `packages/entry/package.json` lists the platform packages as `optionalDependencies`. -- `packages//package.json` declares `os` and `cpu`. There is no `libc` field on purpose: the binaries are statically linked against musl and run on glibc and musl distros alike. -- `packages//prebuilds.json` declares the binaries that may exist in that package (`tool`, `kind`, `path`). -- [support-matrix.md](support-matrix.md) explains why unsupported platform packages are not published. - -`scripts/github-matrix.mjs` derives the CI and Release matrices from these files. `scripts/build.ts` builds only the current host's targets, into `packages//bin/`; it is not a matrix generator. When changing the matrix, update package metadata, `prebuilds.json`, the lockfile, and the support/release docs in the same change. - -## Runtime selection - -1. npm's `os`/`cpu` fields make installers fetch only the matching platform package. -2. The entry package's `launcherPath()` resolves it to `/bin/landlock-run`; unresolvable packages yield a deterministic, never-existing fallback path. -3. `probe()` is the single availability signal: missing binary and unenforcing kernel are deliberately indistinguishable (`unusable`), so consumers have one fail-closed path. - -## No install fallback - -The entry package has NO install script and never compiles on the consumer host. A compile fallback would require a musl toolchain everywhere and turn a clean fail-closed degradation into an environment-dependent maybe. The packed-manifest check in `verify-packed-install.mjs` enforces the absence of install lifecycle scripts. - -## Pack gates - -Platform tarballs are produced by `npm pack`, entry tarballs by `pnpm pack` — deliberately split: `pnpm pack` (observed on 11.7.0) normalizes file modes and strips the executable bit, which would ship a launcher no consumer can spawn, while platform packages have no dependencies and so need none of pnpm's workspace-protocol conversion; entry packages need that conversion and carry no executables. `scripts/pack-release.mjs` encodes the split — never hand-pack a platform package with pnpm. - -Both pack paths produce the exact publish bytes behind a `prepack` gate: - -- Platform packages: `scripts/verify-launcher-binary.mjs` — every declared binary present, executable, ELF `e_machine` matching the declared `cpu`, nothing undeclared in `bin/`. -- Entry packages: `scripts/verify-entry-lib.mjs` — built `lib/` present. - -`scripts/verify-packed-install.mjs` then rehearses the consumer path from the packed tarballs: payload checks, a throwaway install, a byte-pin of the installed binary against the workspace build, an executability check on the installed copy, and a real confinement world-proof through the installed launcher. A non-executable or missing binary fails loudly here instead of masquerading as a non-enforcing kernel. diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md deleted file mode 100644 index d5eec50b6e..0000000000 --- a/native/landlock-run/docs/release.md +++ /dev/null @@ -1,60 +0,0 @@ -# Release - -Pre-1.0: treat this as a release checklist, not a stability policy. - -## Versioning - -The launcher workspace root and its three public packages share one version. Run the bump helper from the repository root: - -```sh -pnpm --dir native/landlock-run release:bump patch # or minor / major / x.y.z -``` - -It updates `native/landlock-run/package.json` and every `native/landlock-run/packages/*` manifest, refreshes the repository root lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm --dir native/landlock-run release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack. - -Version bumps are normal source changes: open a release PR (or commit) with the launcher manifests and root lockfile, merge it, then create the matching `landlock-run-vX.Y.Z` tag from that commit. The namespace avoids colliding with release tags for other package families in the repository. The publish workflow validates that the tag matches every launcher package version. - -```sh -pnpm --dir native/landlock-run release:commit patch # bump + stage + commit in one command -git tag landlock-run-v0.0.2 -``` - -## Preflight - -```sh -pnpm install --frozen-lockfile -pnpm --dir native/landlock-run build:ts -pnpm --dir native/landlock-run typecheck -pnpm --dir native/landlock-run test:entry -``` - -On a Linux host, also rehearse the pack path locally: - -```sh -pnpm --dir native/landlock-run build:native -pnpm --dir native/landlock-run test:launcher -node native/landlock-run/scripts/pack-release.mjs native/landlock-run/.release/npm --current-platform-only -node native/landlock-run/scripts/verify-packed-install.mjs native/landlock-run/.release/npm --current-platform-only -``` - -## Publish - -Use the main repository's `Landlock Run Release` workflow so every binary is built on its matching native runner: - -1. Run it with `publish=false` (from the release commit) to build all platform binaries, assemble and verify the payloads, pack the tarballs in publish order, rehearse the packed install, and upload the `npm-tarballs` artifact for inspection. -2. Create and push the `landlock-run-vX.Y.Z` tag matching the package versions. -3. Run the same workflow from that tag with `publish=true`. - -The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). A current-platform rehearsal can still query npm for metadata about an incompatible optional platform package; that package cannot supply the host launcher, which comes from the matching local tarball. Publishing every platform package before the entry ensures a public entry version never points ahead of its platform packages. The workflow supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. - -The three scoped package names must be bootstrapped with an `@deepseek-ai` organization token through the `NPM_TOKEN` fallback: npm [requires a package to exist before a trusted publisher can be configured](https://docs.npmjs.com/cli/v11/commands/npm-trust/). After the first release creates all three packages, configure each package to trust `landlock-run-release.yml` in this repository with the `npm-publish` environment, then remove the fallback token when organization policy permits it. - -Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): - -```sh -node native/landlock-run/scripts/pack-release.mjs native/landlock-run/dist/npm --current-platform-only -node native/landlock-run/scripts/verify-packed-install.mjs native/landlock-run/dist/npm --current-platform-only -while IFS= read -r tarball; do npm publish "native/landlock-run/dist/npm/${tarball}" --access public; done < native/landlock-run/dist/npm/publish-order.txt -``` - -Do not commit `.npmrc` files with tokens or registry overrides. diff --git a/native/landlock-run/docs/support-matrix.md b/native/landlock-run/docs/support-matrix.md deleted file mode 100644 index e60ad201c1..0000000000 --- a/native/landlock-run/docs/support-matrix.md +++ /dev/null @@ -1,18 +0,0 @@ -# Support matrix - -## Supported - -| Platform package | GitHub runner (builder of record) | Notes | -|---|---|---| -| `@deepseek-ai/node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike | -| `@deepseek-ai/node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike | - -Enforcement additionally requires a kernel with Landlock enabled (5.13+). The negotiated ABI level decides the probe verdict: every access this build knows governed → `full`; an older ABI governing a subset → `partial` (still confined for everything it supports); Landlock absent or disabled → `unusable`, and the launcher refuses to run commands at all. The probe — not the kernel version — is the authority: a kernel built without Landlock, or with the LSM disabled, probes `unusable` regardless of its version. - -## Deliberately unsupported - -- **darwin**: macOS consumers typically confine through `sandbox-exec`/Seatbelt, which ships with the OS — there is no binary to distribute. -- **win32**: a Windows confinement launcher would be a different mechanism in its own repository, not a port of this one. -- **Other Linux architectures** (riscv64, s390x, …): no native CI builder of record yet. The no-cross-toolchain rule means a platform package is added only together with a native runner that builds and proves it. - -A consumer on an unsupported platform resolves a nonexistent launcher path, probes `unusable`, and falls closed — the documented degradation, exercised by CI's darwin leg. diff --git a/native/landlock-run/packages/entry/README.md b/native/landlock-run/packages/entry/README.md deleted file mode 100644 index fff722428c..0000000000 --- a/native/landlock-run/packages/entry/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# @deepseek-ai/node-addon-landlock-run - -English | [中文](README.zh.md) - -Landlock self-restrict-then-exec launcher for confining subprocesses on Linux: this entry package resolves the per-platform prebuilt binary, runs its functional enforcement probe, and builds its grant argv — consumers never spell launcher flags or parse launcher output themselves. - -```js -import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; - -const launcher = launcherPath(); -if (probe(launcher) !== 'unusable') { - const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; -} -``` - -The launcher installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the whole process tree runs confined. Everything not granted is denied, and launcher failures exit `125` without running the command — fail-closed, never fail-open. The binary contract is pinned in the repo's `docs/cli-contract.md`; the C source rides this tarball (`src/main.c`) for audit. - -Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `@deepseek-ai/node-addon-landlock-run-linux-x64`, `@deepseek-ai/node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback. diff --git a/native/landlock-run/packages/entry/README.zh.md b/native/landlock-run/packages/entry/README.zh.md deleted file mode 100644 index cbf867d9dd..0000000000 --- a/native/landlock-run/packages/entry/README.zh.md +++ /dev/null @@ -1,18 +0,0 @@ -# @deepseek-ai/node-addon-landlock-run - -[English](README.md) | 中文 - -用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包定位对应平台的预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 - -```js -import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; - -const launcher = launcherPath(); -if (probe(launcher) !== 'unusable') { - const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; -} -``` - -启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:采用失败闭合策略,绝不在失败时放行。二进制约定锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 - -平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`@deepseek-ai/node-addon-landlock-run-linux-x64`、`@deepseek-ai/node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 diff --git a/native/landlock-run/packages/entry/package.json b/native/landlock-run/packages/entry/package.json deleted file mode 100644 index 5e34cf96f0..0000000000 --- a/native/landlock-run/packages/entry/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@deepseek-ai/node-addon-landlock-run", - "version": "0.1.1", - "type": "module", - "description": "Landlock self-restrict-then-exec launcher for sandboxing subprocesses on Linux: per-platform prebuilt static binaries plus the JS seam that resolves, probes, and speaks their CLI contract", - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", - "directory": "native/landlock-run/packages/entry" - }, - "main": "lib/index.js", - "types": "lib/index.d.ts", - "exports": { - ".": { - "types": "./lib/index.d.ts", - "default": "./lib/index.js" - }, - "./package.json": "./package.json" - }, - "files": [ - "README.md", - "lib/", - "!lib/*.tsbuildinfo", - "src/main.c" - ], - "scripts": { - "build:js": "tsc -b", - "prepack": "node ../../scripts/verify-entry-lib.mjs" - }, - "engines": { - "node": ">=20" - }, - "license": "BSD-3-Clause", - "publishConfig": { - "access": "public" - }, - "optionalDependencies": { - "@deepseek-ai/node-addon-landlock-run-linux-arm64": "workspace:*", - "@deepseek-ai/node-addon-landlock-run-linux-x64": "workspace:*" - } -} diff --git a/native/landlock-run/packages/linux-arm64/README.md b/native/landlock-run/packages/linux-arm64/README.md deleted file mode 100644 index dfcc9e97dc..0000000000 --- a/native/landlock-run/packages/linux-arm64/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# @deepseek-ai/node-addon-landlock-run-linux-arm64 - -English | [中文](README.zh.md) - -Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. - -The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. - -Sibling: `@deepseek-ai/node-addon-landlock-run-linux-x64`. diff --git a/native/landlock-run/packages/linux-arm64/README.zh.md b/native/landlock-run/packages/linux-arm64/README.zh.md deleted file mode 100644 index 350044e92f..0000000000 --- a/native/landlock-run/packages/linux-arm64/README.zh.md +++ /dev/null @@ -1,9 +0,0 @@ -# @deepseek-ai/node-addon-landlock-run-linux-arm64 - -[English](README.md) | 中文 - -面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 - -该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 - -同级包:`@deepseek-ai/node-addon-landlock-run-linux-x64`。 diff --git a/native/landlock-run/packages/linux-arm64/prebuilds.json b/native/landlock-run/packages/linux-arm64/prebuilds.json deleted file mode 100644 index 81e6b429f7..0000000000 --- a/native/landlock-run/packages/linux-arm64/prebuilds.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "platform": "linux-arm64", - "binaries": [ - { - "tool": "landlock-run", - "kind": "static-musl", - "path": "bin/landlock-run" - } - ] -} diff --git a/native/landlock-run/packages/linux-x64/README.md b/native/landlock-run/packages/linux-x64/README.md deleted file mode 100644 index d08cc0c4ab..0000000000 --- a/native/landlock-run/packages/linux-x64/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# @deepseek-ai/node-addon-landlock-run-linux-x64 - -English | [中文](README.zh.md) - -Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. - -The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. - -Sibling: `@deepseek-ai/node-addon-landlock-run-linux-arm64`. diff --git a/native/landlock-run/packages/linux-x64/README.zh.md b/native/landlock-run/packages/linux-x64/README.zh.md deleted file mode 100644 index ed6839aa62..0000000000 --- a/native/landlock-run/packages/linux-x64/README.zh.md +++ /dev/null @@ -1,9 +0,0 @@ -# @deepseek-ai/node-addon-landlock-run-linux-x64 - -[English](README.md) | 中文 - -面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) 包所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 - -该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 - -同级包:`@deepseek-ai/node-addon-landlock-run-linux-arm64`。 diff --git a/native/landlock-run/packages/linux-x64/prebuilds.json b/native/landlock-run/packages/linux-x64/prebuilds.json deleted file mode 100644 index 27b0de360c..0000000000 --- a/native/landlock-run/packages/linux-x64/prebuilds.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "platform": "linux-x64", - "binaries": [ - { - "tool": "landlock-run", - "kind": "static-musl", - "path": "bin/landlock-run" - } - ] -} diff --git a/native/landlock-run/scripts/build.ts b/native/landlock-run/scripts/build.ts deleted file mode 100644 index b8e2d34f12..0000000000 --- a/native/landlock-run/scripts/build.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Build every native tool this host can build, into its per-platform - * package. - * - * Targets are derived from the checked-in matrix: each - * `packages//prebuilds.json` whose `platform` matches this host names - * the binaries to produce; the TOOLS table below maps each `tool` to its C - * source. Builds are NATIVE-ONLY — each Linux architecture compiles its own - * binary with the distro's `musl-gcc` (static musl: runs on glibc and musl - * distros alike, no loader or libc expectations on the consumer host), and - * CI's per-arch runners are the builders of record. No cross toolchain - * exists here on purpose: native runners replace it, and the audit surface - * is the reviewed C source plus the CI job that built the binary. - * - * Binaries land in `packages//bin/` — git-ignored (root - * `.gitignore`), packed into the platform package's npm tarball behind its - * `prepack` gate (`scripts/verify-launcher-binary.mjs`). - * - * Run: `pnpm run build:native` (Linux with musl-gcc on PATH: - * `apt-get install musl-tools`). Non-Linux hosts fail fast — no platform - * package exists for them to build. - */ -import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs' -import { basename, dirname, join, resolve } from 'node:path' - -/** Each native tool's C source, keyed by the `tool` field in prebuilds.json. */ -const TOOLS: Record = { - 'landlock-run': { source: 'packages/entry/src/main.c' }, -} - -const repoRoot = resolve(import.meta.dirname, '..') - -if (process.platform !== 'linux') { - console.error(`build: native tools are built natively per Linux architecture (no cross toolchain) — nothing to build on ${process.platform}. CI's per-arch runners build and rehearse every platform package.`) - process.exit(1) -} -const hostPlatform = `linux-${process.arch}` - -/** This host's platform packages, from the checked-in matrix. */ -const targets: { packageDir: string; tool: string; binaryPath: string; kind: string }[] = [] -const packagesRoot = join(repoRoot, 'packages') -for (const name of readdirSync(packagesRoot).sort()) { - const prebuildsFile = join(packagesRoot, name, 'prebuilds.json') - if (!existsSync(prebuildsFile)) continue - const prebuilds = JSON.parse(readFileSync(prebuildsFile, 'utf8')) as { - platform: string - binaries: { tool: string; kind: string; path: string }[] - } - if (prebuilds.platform !== hostPlatform) continue - for (const binary of prebuilds.binaries) { - targets.push({ packageDir: join(packagesRoot, name), tool: binary.tool, binaryPath: binary.path, kind: binary.kind }) - } -} -if (targets.length === 0) { - console.error(`build: no platform package declares binaries for ${hostPlatform} — supported platforms are the packages/*/prebuilds.json "platform" values.`) - process.exit(1) -} - -for (const target of targets) { - const tool = TOOLS[target.tool] - if (tool === undefined) { - console.error(`build: prebuilds.json names unknown tool "${target.tool}" — add it to the TOOLS table in scripts/build.ts.`) - process.exit(1) - } - if (target.kind !== 'static-musl') { - console.error(`build: unknown binary kind "${target.kind}" — the only toolchain here is static musl.`) - process.exit(1) - } - const binary = join(target.packageDir, target.binaryPath) - mkdirSync(dirname(binary), { recursive: true }) - - // -static against musl: self-contained, no loader/libc expectations on the - // consumer host. -Werror is safe to keep hard: CI pins the builder images, - // and a new warning on a toolchain bump deserves a look, not a pass. - const result = spawnSync('musl-gcc', [ - '-std=c11', '-Os', '-Wall', '-Wextra', '-Werror', '-static', '-s', - '-o', binary, join(repoRoot, tool.source), - ], { stdio: ['ignore', 'inherit', 'inherit'] }) - if (result.error !== undefined || result.status !== 0) { - console.error('build: musl-gcc failed' + - (result.error ? ` (${result.error.message} — is musl-tools installed?)` : '')) - process.exit(1) - } - console.log(`build: built ${basename(target.packageDir)}/${target.binaryPath}`) -} diff --git a/native/landlock-run/scripts/repo.mjs b/native/landlock-run/scripts/repo.mjs deleted file mode 100644 index db5ffaf28d..0000000000 --- a/native/landlock-run/scripts/repo.mjs +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node -/** - * Shared helpers for the repo scripts: package discovery, the checked-in - * prebuild matrix, and binary verification. The package matrix is explicit - * metadata — `packages//prebuilds.json` marks a platform package and - * declares its binaries; everything else under `packages/` is an entry - * package. Scripts derive from these files and never guess. - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -export const root = fileURLToPath(new URL('..', import.meta.url)); -const packagesRoot = path.join(root, 'packages'); - -/** ELF `e_machine` (offset 18, little-endian) per platform-package `cpu` value. */ -const E_MACHINE = { x64: 62, arm64: 183 }; - -export function readJson(file) { - return JSON.parse(fs.readFileSync(file, 'utf8')); -} - -/** Platform packages: every `packages/` carrying a `prebuilds.json`. */ -export function platformDirs() { - return fs.readdirSync(packagesRoot) - .filter((name) => fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json'))) - .sort() - .map((name) => path.join('packages', name)); -} - -/** Entry packages: every other `packages/` with a `package.json`. */ -export function entryDirs() { - return fs.readdirSync(packagesRoot) - .filter((name) => !fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json'))) - .filter((name) => fs.existsSync(path.join(packagesRoot, name, 'package.json'))) - .sort() - .map((name) => path.join('packages', name)); -} - -/** All published packages in publish order: platform packages before the entries that optionally depend on them. */ -export function packageDirs() { - return [...platformDirs(), ...entryDirs()]; -} - -/** - * Verify one platform package's binaries against its `prebuilds.json`: - * every declared binary exists, nothing undeclared sits in `bin/`, and each - * file's ELF `e_machine` matches the package's declared `cpu`. Throws with - * a remediation message on the first mismatch. - */ -export function verifyPlatformBinaries(packageDir) { - const manifest = readJson(path.join(packageDir, 'package.json')); - const prebuilds = readJson(path.join(packageDir, 'prebuilds.json')); - const cpu = manifest.cpu?.[0]; - if (cpu === undefined || !(cpu in E_MACHINE)) { - throw new Error(`${manifest.name}: unsupported or missing "cpu" in package.json (expected one of: ${Object.keys(E_MACHINE).join(', ')})`); - } - - for (const binary of prebuilds.binaries) { - const file = path.join(packageDir, binary.path); - if (!fs.existsSync(file)) { - throw new Error(`${manifest.name}: missing ${binary.path} — run \`pnpm build:native\` on a ${prebuilds.platform} host (or assemble release artifacts) before packing.`); - } - try { - fs.accessSync(file, fs.constants.X_OK); - } catch { - // Only reachable when the mode was mangled somewhere between build and - // here (e.g. an archive step that normalized permissions) — the build - // itself always produces 755. - throw new Error(`${manifest.name}: ${binary.path} is not executable — a pack/extract step stripped the mode bit.`); - } - const machine = fs.readFileSync(file).readUInt16LE(18); - if (machine !== E_MACHINE[cpu]) { - throw new Error(`${manifest.name}: ${binary.path} has ELF e_machine ${machine}, expected ${E_MACHINE[cpu]} for ${cpu} — the binary was built for a different architecture.`); - } - } - - const declared = prebuilds.binaries.map((binary) => path.basename(binary.path)).sort(); - const binDir = path.join(packageDir, 'bin'); - const actual = fs.existsSync(binDir) ? fs.readdirSync(binDir).sort() : []; - const extra = actual.filter((name) => !declared.includes(name)); - if (extra.length) { - throw new Error(`${manifest.name}: bin/ contains files not declared in prebuilds.json: ${extra.join(', ')}`); - } - - return { name: manifest.name, count: prebuilds.binaries.length }; -} diff --git a/native/landlock-run/.gitignore b/native/system/.gitignore similarity index 100% rename from native/landlock-run/.gitignore rename to native/system/.gitignore diff --git a/native/system/AGENTS.md b/native/system/AGENTS.md new file mode 100644 index 0000000000..8234f941fd --- /dev/null +++ b/native/system/AGENTS.md @@ -0,0 +1,28 @@ +# AGENTS.md + +This workspace owns `@deepseek-ai/node-addon-system`: the Linux `landlock-run` confinement executable and the POSIX `system.node` binding. It shares the root pnpm workspace and lockfile; native packages have one independent version and release workflow. + +## Runtime rules + +- Landlock's argv, exit codes, diagnostics, and fail-closed confinement are defined in [docs/cli-contract.md](docs/cli-contract.md). Do not change them when extending another system capability. +- The launcher uses only libc, statically linked against musl. Its kernel UAPI definitions remain in the reviewed C source. +- Node bindings use stable Node-API v8, never NAN, V8 C++ APIs, or experimental Node interfaces. Linux glibc and musl addons are distinct binaries; macOS has its own Mach-O bundle. +- The flock binding attempts only `LOCK_EX | LOCK_NB` in asynchronous work and captures errno on that worker. The caller owns the fd through completion and releases its lock by closing it. +- `./landlock-run` and `./flock` are independent capability exports; the package has no root export. Neither import loads the addon. `./flock` loads it only when called; Windows retains the Harness's existing semaphore implementation. +- Runtime binary selection has no environment-variable overrides. `NALR_REQUIRE_LANDLOCK` is a test-only enforcement requirement. +- There is no install-time compile fallback. Missing Landlock binaries probe unusable; missing flock bindings reject acquisition, never silently grant a lock. + +## Layout and commands + +`packages/entry/` owns JavaScript, types, and auditable C sources. Platform packages hold only binaries and metadata. `scripts/` owns native builds, packing, validation, and release; `test/` owns real process and lock behavior. + +Run `pnpm build:ts`, `pnpm build:native`, `pnpm build:test-oracle`, `pnpm typecheck`, and `pnpm test` in this directory. Linux full builds require musl-gcc; macOS uses cc. Repository tests build only their host addon through the root `build:native-system` script. The independent syscall fixture is test-only and never enters a published platform package. + +## Packaging and verification + +- `os`/`cpu` and `prebuilds.json` are the checked-in package matrix. CI derives runners from that matrix, builds natively on each architecture, and tests identical addon bytes under several Node releases. +- Linux packages contain `bin/landlock-run`, `bin/glibc/system.node`, and `bin/musl/system.node`. macOS packages contain `bin/system.node`. No Windows platform package is needed by these capabilities. +- Platform prepack rejects missing, undeclared, wrong-format, wrong-architecture, and non-Node-API addon payloads. Launcher executability is checked separately. +- Platform tarballs use npm pack to preserve executable permissions. The entry uses pnpm pack for workspace version conversion. +- Packed-install verification checks manifests, installs local tarballs without a registry, byte-pins payloads, and exercises both the installed flock binding and Landlock's functional probe. +- Build outputs stay ignored. Source/consumer changes and their behavior tests land together; preserve bilingual READMEs and independent native publication. diff --git a/native/landlock-run/LICENSE b/native/system/LICENSE similarity index 100% rename from native/landlock-run/LICENSE rename to native/system/LICENSE diff --git a/native/landlock-run/README.i18n.yaml b/native/system/README.i18n.yaml similarity index 56% rename from native/landlock-run/README.i18n.yaml rename to native/system/README.i18n.yaml index 6eb3457bdb..1e1eb1cd92 100644 --- a/native/landlock-run/README.i18n.yaml +++ b/native/system/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write native/landlock-run/README.md -README.md: bf9b163d42a9b7402dbf4c045d47e7b8d36e15ab -README.zh.md: 7bd4765a73f0d5df2a4710ce06ff319f6c902db6 +# pnpm run verify-translation-pairing --write native/system/README.md +README.md: 5a01754cb96fabbdfd2dbf4f068f19addd7a2347 +README.zh.md: dabd00cecd05ded13a50d2efd000985afed713d4 diff --git a/native/system/README.md b/native/system/README.md new file mode 100644 index 0000000000..5a01754cb9 --- /dev/null +++ b/native/system/README.md @@ -0,0 +1,41 @@ +--- +description: "Prebuilt system primitives for Linux confinement and POSIX Session write locks." +kind: "package-library" +--- +# @deepseek-ai/node-addon-system + +English | [中文](README.zh.md) + +## Summary + +Use the Linux `landlock-run` executable to confine subprocesses, or the `./flock` entry to acquire a POSIX write lock. Platform packages contain the precompiled binaries; consumer installation never builds native code. Landlock policy and Session lifecycle remain with callers. + +## Table of Contents + +- [Use](#use) +- [Support](#support) +- [Development](#development) + +## Use + +`@deepseek-ai/node-addon-system/landlock-run` exports `launcherPath`, `probe`, and `grantArgs` for Landlock. Its executable name, flags, and failure semantics are defined by the [CLI contract](docs/cli-contract.md). + +The [flock behavior contract](docs/flock-contract.md) maps descriptor, process, and advisory-lock semantics to independent native tests. + +`@deepseek-ai/node-addon-system/flock` exports `tryLockExclusive(fd): Promise`. Keep the descriptor open until completion. Acquisition uses nonblocking exclusive flock; contention rejects with `EAGAIN` or `EWOULDBLOCK`, and closing the final descriptor for the open file description releases the lock. See the [entry README](packages/entry/README.md). + +Importing either entry does not load an addon. A missing Landlock executable probes unusable; a missing flock binding rejects acquisition. Neither path compiles or silently grants unsupported behavior. + +## Support + +Linux x64/arm64 packages contain the static Landlock executable and separate glibc/musl `system.node` files. macOS x64/arm64 packages contain `system.node` only. Landlock additionally needs an enforcing Linux kernel; Windows uses the Harness's existing locking implementation. The [support matrix](docs/support-matrix.md) names builders and verification owners. + +## Development + +From this directory, `pnpm build:ts` builds the entry, `pnpm build:native` builds the host's declared native payload, and `pnpm build:test-oracle` builds an independent flock syscall fixture. Then `pnpm test` exercises entry, lock, packaging, and available kernel behavior. Linux requires musl-gcc for a complete build; macOS uses cc. The root `pnpm run build:native-system` builds only the current host addon for source tests. + +The [architecture](docs/architecture.md), [packaging](docs/packaging.md), and [release procedure](docs/release.md) own implementation and publication details. + +### Dev Note + +None. diff --git a/native/system/README.zh.md b/native/system/README.zh.md new file mode 100644 index 0000000000..dabd00cecd --- /dev/null +++ b/native/system/README.zh.md @@ -0,0 +1,41 @@ +--- +description: "为 Linux 进程隔离与 POSIX Session 写锁提供预编译系统原语。" +kind: "package-library" +--- +# @deepseek-ai/node-addon-system + +[English](README.md) | 中文 + +## Summary + +使用 Linux `landlock-run` 可执行文件限制子进程,或通过 `./flock` 入口获取 POSIX 写锁。平台包包含预编译二进制;用户安装时不会构建原生代码。Landlock 策略与 Session 生命周期仍由调用方负责。 + +## Table of Contents + +- [使用](#use) +- [支持范围](#support) +- [开发](#development) + +## Use + +`@deepseek-ai/node-addon-system/landlock-run` 为 Landlock 导出 `launcherPath`、`probe` 和 `grantArgs`。其可执行文件名、参数和失败语义由 [CLI 约定](docs/cli-contract.md) 定义。 + +[flock 行为约定](docs/flock-contract.md) 将描述符、进程和咨询式锁语义对应到独立原生测试。 + +`@deepseek-ai/node-addon-system/flock` 导出 `tryLockExclusive(fd): Promise`。在调用完成前保持描述符打开。获取操作使用非阻塞独占 flock;竞争以 `EAGAIN` 或 `EWOULDBLOCK` 拒绝,关闭该打开文件描述的最后一个描述符即释放锁。参见[入口 README](packages/entry/README.zh.md)。 + +导入任一入口都不会加载 addon。Landlock 可执行文件缺失时探测为不可用;flock 绑定缺失时拒绝获取。两条路径都不会编译或静默授予不受支持的行为。 + +## Support + +Linux x64/arm64 包包含静态 Landlock 可执行文件,以及分别用于 glibc/musl 的 `system.node` 文件。macOS x64/arm64 包仅包含 `system.node`。Landlock 还需要支持强制执行的 Linux 内核;Windows 使用 Harness 既有锁实现。[支持矩阵](docs/support-matrix.md) 指定构建者与验证负责人。 + +## Development + +在本目录运行 `pnpm build:ts` 构建入口、`pnpm build:native` 构建当前宿主声明的原生产物、`pnpm build:test-oracle` 构建独立的 flock 系统调用 fixture。随后用 `pnpm test` 验证入口、锁、打包及可用的内核行为。Linux 完整构建需要 musl-gcc;macOS 使用 cc。根目录 `pnpm run build:native-system` 只构建源码测试所需的当前宿主 addon。 + +[架构](docs/architecture.md)、[打包](docs/packaging.md)和[发布流程](docs/release.md)分别负责实现与发布细节。 + +### Dev Note + +无。 diff --git a/native/system/docs/architecture.md b/native/system/docs/architecture.md new file mode 100644 index 0000000000..7c258752a3 --- /dev/null +++ b/native/system/docs/architecture.md @@ -0,0 +1,25 @@ +# Architecture + +The system package family supplies native mechanisms to Node callers: a Linux confinement executable and a POSIX file-lock binding. Consumers own sandbox policy and Session lifecycle. + +## Package family + +The ESM package `@deepseek-ai/node-addon-system` and its optional platform packages share one version. Platform metadata chooses the operating system and CPU; each package's `prebuilds.json` declares the files it must contain. + +The `./landlock-run` entry owns Landlock path resolution, grant argv, and the functional probe. It does not load native addons. The `./flock` entry lazily loads `system.node` only when `tryLockExclusive(fd)` is called. Importing either JavaScript entry therefore works without a matching native payload. The package exposes these capability subpaths and its manifest, without a root export. + +## Separate mechanisms + +`landlock-run` remains a static musl executable with the [existing CLI contract](cli-contract.md). It installs confinement on itself before exec, and refuses to exec if enforcement is unavailable. A missing launcher or unsupported kernel produces an unusable probe. + +`system.node` uses stable Node-API v8. Its flock operation follows [fs-ext's asynchronous callback model](https://github.com/baudehlo/node-fs-ext/blob/v2.1.1/fs-ext.cc): it runs `flock(fd, LOCK_EX | LOCK_NB)` in asynchronous work and records errno on that worker. The native callback receives zero or positive errno; JavaScript owns the promise and syscall error construction. Setup errors throw into that promise. Callback exceptions are reported through Node's uncaught-exception handler; unexpected Node-API failures terminate the process. A terminating environment may suppress JavaScript completion, but its cleanup waits for queued or running native work before freeing storage. + +The descriptor belongs to the caller and must stay open through completion. The binding neither opens nor closes it; closing the final descriptor for its open file description releases the lock. + +The JSONL backend retains its inode check, materialization timing, and close lifecycle. Windows uses its existing koffi semaphore and never calls this POSIX binding. The browser worker supplies a single-process replacement for the flock entry, while running the Landlock JavaScript API unchanged. + +## Builds and release + +Repository builds and `build:bench` explicitly build the host addon before running consumers. Each platform builds natively on its CI runner. Landlock is static-musl; Linux addons are separately built for glibc and musl, and macOS uses a Mach-O bundle. Stable Node-API removes the Node-major build dimension, not OS, CPU, or libc differences. CI exercises identical addon bytes under Node 20, 22, 24, and 26; Linux also runs the musl addon in Alpine containers. + +Platform prepack validates file formats, architecture, payload completeness, and Node-API exports. The packed-install rehearsal installs local tarballs, checks their bytes, and exercises the installed mechanisms. Missing capabilities fail explicitly; no consumer install runs a compiler. [Packaging](packaging.md) and [release](release.md) own the operational details. diff --git a/native/landlock-run/docs/cli-contract.md b/native/system/docs/cli-contract.md similarity index 100% rename from native/landlock-run/docs/cli-contract.md rename to native/system/docs/cli-contract.md diff --git a/native/system/docs/flock-contract.md b/native/system/docs/flock-contract.md new file mode 100644 index 0000000000..dafbdbb3a3 --- /dev/null +++ b/native/system/docs/flock-contract.md @@ -0,0 +1,36 @@ +# POSIX flock behavior + +`tryLockExclusive(fd)` returns a promise for one `flock(fd, LOCK_EX | LOCK_NB)` attempt. The syscall runs off the JavaScript thread. The caller keeps the descriptor open through completion; the binding does not open, duplicate, or close it. It exposes neither a blocking-wait API nor a shared-lock API. + +## Behavior tests + +The [native tests](../test/flock.test.js) exercise real descriptors and independent processes. The [C oracle](../test/fixtures/flock-oracle.c) calls the operating system directly, independently of `system.node`. + +| Condition | Required observation | +|---|---| +| No conflicting lock | Acquisition resolves to void | +| Same open file description acquires again | Acquisition succeeds without a second ownership record | +| Separate opens of the same file | Exactly one exclusive holder; the contender rejects with EAGAIN/EWOULDBLOCK | +| Different files | Both can be locked | +| Independent C flock holder | The addon cannot acquire, and the C oracle cannot acquire while the addon holds the lock | +| A shared flock holder | The addon's exclusive attempt conflicts | +| Holder remains live | A nonblocking attempt reports contention before the holder unlocks | +| Ordinary read/write by another process | Access is allowed: flock is advisory, not an I/O permission mechanism | +| One unrelated descriptor closes | The actual holder keeps its lock | +| A descriptor inherited by a child remains open | Closing the parent's descriptor does not release the shared open file description's lock | +| Last owning descriptor closes | An already-open contender can acquire | +| Holder process exits or is killed | Acquisition succeeds after process exit, without a stale-lock timeout | +| Invalid descriptor | The promise rejects with EBADF and positive errno | +| Native argument validation fails | The JavaScript entry returns a rejected promise without throwing synchronously | +| Native completion callback | It receives zero or the request's positive errno asynchronously | +| Native completion callback throws | The exception reaches Node's uncaught-exception handler | +| Concurrent success/failure calls | Each completion receives its own syscall errno | +| Worker environment terminates before or during its callback | Native work and cleanup reach completion without taking ownership of the caller's descriptor | + +Tests synchronize through IPC or flushed line protocols and await process exit before asserting crash recovery. They do not use fixed sleeps or a millisecond performance threshold to prove nonblocking behavior. The syscall oracle is built only for tests and never included in a published platform package. + +## Limits + +Locks belong to open file descriptions and follow the host filesystem's flock semantics. Removing or replacing a pathname does not transfer a lock to the replacement inode; the JSONL backend separately checks inode identity. Network filesystems can have different or unsupported lock semantics. Windows does not use this API and retains its existing semaphore implementation. + +Node-API compatibility tests reuse the same platform addon under different Node versions. They complement these syscall tests; loading a binary alone does not prove correct locking behavior. diff --git a/native/landlock-run/docs/naming.md b/native/system/docs/naming.md similarity index 60% rename from native/landlock-run/docs/naming.md rename to native/system/docs/naming.md index a1f2665654..bc82176b51 100644 --- a/native/landlock-run/docs/naming.md +++ b/native/system/docs/naming.md @@ -2,18 +2,18 @@ ## npm packages -The public package family belongs to the `@deepseek-ai` scope and uses the `node-addon-landlock-run` package prefix; platform packages append platform information only: +The public package family belongs to the `@deepseek-ai` scope and uses the `node-addon-system` package prefix; platform packages append platform information only: ```text -@deepseek-ai/node-addon-landlock-run -@deepseek-ai/node-addon-landlock-run- +@deepseek-ai/node-addon-system +@deepseek-ai/node-addon-system- ``` -Platform suffixes carry no libc component (binaries are static musl) and no variant component — variants stay inside `prebuilds.json` and binary filenames. +Platform suffixes carry OS and CPU. Linux libc variants live inside the same platform package and are declared in `prebuilds.json`. ## Binaries -The launcher executable is `landlock-run`, shipped at `bin/landlock-run` inside each platform package. +The Linux launcher remains `bin/landlock-run`. The Node-API addon is `system.node`: `bin/glibc/system.node` and `bin/musl/system.node` on Linux, `bin/system.node` on macOS. ## Environment variables diff --git a/native/system/docs/packaging.md b/native/system/docs/packaging.md new file mode 100644 index 0000000000..5ca2320612 --- /dev/null +++ b/native/system/docs/packaging.md @@ -0,0 +1,24 @@ +# Packaging + +The family publishes one ESM entry package plus OS/CPU-selected optional platform packages. All share one version; platform packages publish before the entry. + +## Payloads + +The entry package exports the Landlock API at `./landlock-run` and the asynchronous lock API at `./flock`, with C sources included for auditability. There is no root export. Platform packages contain no JavaScript. + +- Linux: `bin/landlock-run`, `bin/glibc/system.node`, and `bin/musl/system.node`. +- macOS: `bin/system.node`. + +`package.json` supplies OS/CPU metadata; `prebuilds.json` supplies tool, binary kind, path, and addon Node-API/libc metadata. CI matrices and release assembly derive from those files. Nested paths remain intact in uploaded artifacts and tarballs. + +## Installation and use + +Neither entry nor platform packages have installation lifecycle scripts. The entry resolves its matching optional package when a native operation needs it. Optional means that the package manager selects a platform, not that a requested lock can succeed without its binding. + +The `./landlock-run` API stays importable without native payloads and reports unavailable enforcement through its probe. The flock entry is also lazy at import; acquisition reports a missing or unloadable addon instead of compiling or granting an unprotected lock. + +## Pack verification + +Platform tarballs use npm pack to preserve the launcher's executable bit. The entry uses pnpm pack to convert workspace dependency versions. Prepack rejects missing or undeclared payloads, invalid ELF/Mach-O architecture or type, addons without Node-API exports, and launchers without executable permission. + +The installed-artifact rehearsal verifies concrete dependency versions and absence of installation hooks, performs an offline npm install from local tarballs, and compares installed bytes with build outputs. It then proves flock contention/close release and probes the installed Landlock launcher; real confinement remains required on enforcing CI kernels. diff --git a/native/system/docs/release.md b/native/system/docs/release.md new file mode 100644 index 0000000000..650156576e --- /dev/null +++ b/native/system/docs/release.md @@ -0,0 +1,63 @@ +# Release + +Pre-1.0: treat this as a release checklist, not a stability policy. + +## Versioning + +The native workspace root and every platform/entry package share one version. Run the bump helper from the repository root: + +```sh +pnpm --dir native/system release:bump patch # or minor / major / x.y.z +``` + +It updates `native/system/package.json` and every `native/system/packages/*` manifest, refreshes the repository root lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm --dir native/system release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack. + +Version bumps are normal source changes: open a release PR (or commit) with the launcher manifests and root lockfile, merge it, then create the matching `node-addon-system-vX.Y.Z` tag from that commit. The namespace avoids colliding with release tags for other package families in the repository. The publish workflow validates that the tag matches every launcher package version. + +```sh +pnpm --dir native/system release:commit patch # bump + stage + commit in one command +git tag node-addon-system-v0.0.2 +``` + +## Preflight + +```sh +pnpm install --frozen-lockfile +pnpm --dir native/system build:ts +pnpm --dir native/system typecheck +pnpm --dir native/system test:entry +``` + +On a supported Linux or macOS host, also rehearse the pack path locally: + +```sh +pnpm --dir native/system build:native +pnpm --dir native/system build:test-oracle +pnpm --dir native/system test:launcher +pnpm --dir native/system test:flock +pnpm --dir native/system test:packaging +node native/system/scripts/pack-release.mjs native/system/.release/npm --current-platform-only +node native/system/scripts/verify-packed-install.mjs native/system/.release/npm --current-platform-only +``` + +## Publish + +Use the main repository's `Node Addon System Release` workflow so every binary is built on its matching native runner: + +1. Run it with `publish=false` (from the release commit) to build all platform binaries, assemble and verify the payloads, pack the tarballs in publish order, rehearse the packed install, and upload the `npm-tarballs` artifact for inspection. +2. Create and push the `node-addon-system-vX.Y.Z` tag matching the package versions. +3. Run the same workflow from that tag with `publish=true`. + +The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). The current-platform rehearsal uses offline npm installation; the current entry and platform package come from local tarballs. Publishing every platform package before the entry ensures a public entry version never points ahead of its platform packages. The workflow supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. + +New scoped package names must be bootstrapped with an `@deepseek-ai` organization token through the `NPM_TOKEN` fallback: npm [requires a package to exist before a trusted publisher can be configured](https://docs.npmjs.com/cli/v11/commands/npm-trust/). After the first release creates the packages, configure each package to trust `node-addon-system-release.yml` in this repository with the `npm-publish` environment, then remove the fallback token when organization policy permits it. + +Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): + +```sh +node native/system/scripts/pack-release.mjs native/system/dist/npm --current-platform-only +node native/system/scripts/verify-packed-install.mjs native/system/dist/npm --current-platform-only +while IFS= read -r tarball; do npm publish "native/system/dist/npm/${tarball}" --access public; done < native/system/dist/npm/publish-order.txt +``` + +Do not commit `.npmrc` files with tokens or registry overrides. diff --git a/native/system/docs/support-matrix.md b/native/system/docs/support-matrix.md new file mode 100644 index 0000000000..c499a73b8b --- /dev/null +++ b/native/system/docs/support-matrix.md @@ -0,0 +1,14 @@ +# Support matrix + +| Platform package suffix | Builder | Payload | +|---|---|---| +| linux-x64 | ubuntu-24.04 | static Landlock executable; glibc and musl system.node | +| linux-arm64 | ubuntu-24.04-arm | static Landlock executable; glibc and musl system.node | +| darwin-x64 | macos-15-intel | system.node | +| darwin-arm64 | macos-latest | system.node | + +The stable Node-API v8 addon is built once per platform/libc and exercised by CI under Node 20, 22, 24, and 26. macOS builds target 11.0 or later. Linux binding selection uses the running Node process's libc; the static launcher serves both libc variants. + +Landlock additionally requires an enforcing Linux kernel. The functional probe determines full, partial, or unusable enforcement; kernel version alone is not an availability guarantee. + +Windows has neither a Landlock launcher nor this POSIX addon. The Harness retains its existing Windows semaphore implementation. Other CPU/OS combinations have no published platform package: Landlock probes unusable, and flock acquisition rejects. New platform support requires a native builder and installed-artifact verification. diff --git a/native/landlock-run/package.json b/native/system/package.json similarity index 72% rename from native/landlock-run/package.json rename to native/system/package.json index be0ca9732d..713644d9e6 100644 --- a/native/landlock-run/package.json +++ b/native/system/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/node-addon-landlock-run-workspace", - "version": "0.1.1", + "name": "@deepseek-ai/node-addon-system-workspace", + "version": "0.1.2", "private": true, "type": "module", "license": "BSD-3-Clause", @@ -9,10 +9,13 @@ "build": "pnpm build:ts", "build:ts": "tsc -b", "build:native": "tsx ./scripts/build.ts", + "build:test-oracle": "node ./scripts/build-test-oracle.mjs", "typecheck": "tsc --noEmit && tsc -b --dry", - "test": "node ./test/entry.test.js && node ./test/launcher.test.js", + "test": "node ./test/entry.test.js && node ./test/launcher.test.js && node --test ./test/flock.test.js ./test/package-matrix.test.js", "test:entry": "node ./test/entry.test.js", "test:launcher": "node ./test/launcher.test.js", + "test:flock": "node --test ./test/flock.test.js", + "test:packaging": "node --test ./test/package-matrix.test.js", "gha:matrix": "node ./scripts/github-matrix.mjs", "release:bump": "node ./scripts/bump-release.mjs", "release:commit": "node ./scripts/commit-release.mjs", @@ -23,7 +26,7 @@ "release:verify-packed-install": "node ./scripts/verify-packed-install.mjs" }, "devDependencies": { - "@deepseek-ai/node-addon-landlock-run": "workspace:*", + "@deepseek-ai/node-addon-system": "workspace:*", "@types/node": "^26.0.1", "tsx": "^4.20.6", "typescript": "^6.0.3" diff --git a/native/landlock-run/packages/linux-arm64/LICENSE b/native/system/packages/darwin-arm64/LICENSE similarity index 100% rename from native/landlock-run/packages/linux-arm64/LICENSE rename to native/system/packages/darwin-arm64/LICENSE diff --git a/native/landlock-run/packages/linux-x64/README.i18n.yaml b/native/system/packages/darwin-arm64/README.i18n.yaml similarity index 54% rename from native/landlock-run/packages/linux-x64/README.i18n.yaml rename to native/system/packages/darwin-arm64/README.i18n.yaml index cb0022b138..cce14d5da1 100644 --- a/native/landlock-run/packages/linux-x64/README.i18n.yaml +++ b/native/system/packages/darwin-arm64/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write native/landlock-run/packages/linux-x64/README.md -README.md: d08cc0c4abbc74f64c5d1075dea796427211bd8f -README.zh.md: ed6839aa6230b16b82c67a716fc0a4128e5a977c +# pnpm run verify-translation-pairing --write native/system/packages/darwin-arm64/README.md +README.md: 71cfaacc9bc442341b3a9d57389c2cb1904fbb70 +README.zh.md: 5e54c8155009b98e3336ea612535ce08e56817ae diff --git a/native/system/packages/darwin-arm64/README.md b/native/system/packages/darwin-arm64/README.md new file mode 100644 index 0000000000..71cfaacc9b --- /dev/null +++ b/native/system/packages/darwin-arm64/README.md @@ -0,0 +1,9 @@ +--- +description: "Prebuilt system.node for macOS arm64 POSIX locks." +kind: "package-library" +--- +# @deepseek-ai/node-addon-system-darwin-arm64 + +English | [中文](README.zh.md) + +This platform package supplies `bin/system.node`, a stable Node-API v8 addon used by `@deepseek-ai/node-addon-system/flock`. It contains no Landlock executable, JavaScript loader, or installation build script. The native workflow builds it on macOS arm64 and owns its installed-artifact validation. diff --git a/native/system/packages/darwin-arm64/README.zh.md b/native/system/packages/darwin-arm64/README.zh.md new file mode 100644 index 0000000000..5e54c81550 --- /dev/null +++ b/native/system/packages/darwin-arm64/README.zh.md @@ -0,0 +1,9 @@ +--- +description: "为 macOS arm64 POSIX 锁提供预编译 system.node。" +kind: "package-library" +--- +# @deepseek-ai/node-addon-system-darwin-arm64 + +[English](README.md) | 中文 + +此平台包提供 `bin/system.node`,供 `@deepseek-ai/node-addon-system/flock` 使用的稳定 Node-API v8 addon。它不包含 Landlock 可执行文件、JavaScript 加载器或安装构建脚本。Native 工作流在 macOS arm64 上构建它,并负责验证安装后的产物。 diff --git a/native/system/packages/darwin-arm64/package.json b/native/system/packages/darwin-arm64/package.json new file mode 100644 index 0000000000..caf80eb3e2 --- /dev/null +++ b/native/system/packages/darwin-arm64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@deepseek-ai/node-addon-system-darwin-arm64", + "version": "0.1.2", + "description": "Prebuilt POSIX flock Node-API binding for macOS arm64", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/system/packages/darwin-arm64" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "README.md", + "bin/", + "prebuilds.json" + ], + "scripts": { + "prepack": "node ../../scripts/verify-launcher-binary.mjs" + }, + "engines": { + "node": ">=20" + }, + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + } +} diff --git a/native/system/packages/darwin-arm64/prebuilds.json b/native/system/packages/darwin-arm64/prebuilds.json new file mode 100644 index 0000000000..8b3ba78d63 --- /dev/null +++ b/native/system/packages/darwin-arm64/prebuilds.json @@ -0,0 +1,6 @@ +{ + "platform": "darwin-arm64", + "binaries": [ + { "tool": "flock", "kind": "node-api", "napi": 8, "path": "bin/system.node" } + ] +} diff --git a/native/landlock-run/packages/linux-x64/LICENSE b/native/system/packages/darwin-x64/LICENSE similarity index 100% rename from native/landlock-run/packages/linux-x64/LICENSE rename to native/system/packages/darwin-x64/LICENSE diff --git a/native/landlock-run/packages/entry/README.i18n.yaml b/native/system/packages/darwin-x64/README.i18n.yaml similarity index 54% rename from native/landlock-run/packages/entry/README.i18n.yaml rename to native/system/packages/darwin-x64/README.i18n.yaml index 1a701c02bc..ff5479220b 100644 --- a/native/landlock-run/packages/entry/README.i18n.yaml +++ b/native/system/packages/darwin-x64/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write native/landlock-run/packages/entry/README.md -README.md: fff722428c5d213d9fcce0ee87a1d48cdc189884 -README.zh.md: cbf867d9dd3551a12b74b19a6ceaf2be3e1c53a6 +# pnpm run verify-translation-pairing --write native/system/packages/darwin-x64/README.md +README.md: 1740788eef943373591a895baeb7207635972a46 +README.zh.md: 644165743486a29e48bee7af33429ebf4f5d8fe2 diff --git a/native/system/packages/darwin-x64/README.md b/native/system/packages/darwin-x64/README.md new file mode 100644 index 0000000000..1740788eef --- /dev/null +++ b/native/system/packages/darwin-x64/README.md @@ -0,0 +1,9 @@ +--- +description: "Prebuilt system.node for macOS x64 POSIX locks." +kind: "package-library" +--- +# @deepseek-ai/node-addon-system-darwin-x64 + +English | [中文](README.zh.md) + +This platform package supplies `bin/system.node`, a stable Node-API v8 addon used by `@deepseek-ai/node-addon-system/flock`. It contains no Landlock executable, JavaScript loader, or installation build script. The native workflow builds it on macOS x64 and owns its installed-artifact validation. diff --git a/native/system/packages/darwin-x64/README.zh.md b/native/system/packages/darwin-x64/README.zh.md new file mode 100644 index 0000000000..6441657434 --- /dev/null +++ b/native/system/packages/darwin-x64/README.zh.md @@ -0,0 +1,9 @@ +--- +description: "为 macOS x64 POSIX 锁提供预编译 system.node。" +kind: "package-library" +--- +# @deepseek-ai/node-addon-system-darwin-x64 + +[English](README.md) | 中文 + +此平台包提供 `bin/system.node`,供 `@deepseek-ai/node-addon-system/flock` 使用的稳定 Node-API v8 addon。它不包含 Landlock 可执行文件、JavaScript 加载器或安装构建脚本。Native 工作流在 macOS x64 上构建它,并负责验证安装后的产物。 diff --git a/native/system/packages/darwin-x64/package.json b/native/system/packages/darwin-x64/package.json new file mode 100644 index 0000000000..67b577e550 --- /dev/null +++ b/native/system/packages/darwin-x64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@deepseek-ai/node-addon-system-darwin-x64", + "version": "0.1.2", + "description": "Prebuilt POSIX flock Node-API binding for macOS x64", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/system/packages/darwin-x64" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "README.md", + "bin/", + "prebuilds.json" + ], + "scripts": { + "prepack": "node ../../scripts/verify-launcher-binary.mjs" + }, + "engines": { + "node": ">=20" + }, + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + } +} diff --git a/native/system/packages/darwin-x64/prebuilds.json b/native/system/packages/darwin-x64/prebuilds.json new file mode 100644 index 0000000000..f13bcc5d5d --- /dev/null +++ b/native/system/packages/darwin-x64/prebuilds.json @@ -0,0 +1,6 @@ +{ + "platform": "darwin-x64", + "binaries": [ + { "tool": "flock", "kind": "node-api", "napi": 8, "path": "bin/system.node" } + ] +} diff --git a/native/system/packages/entry/README.i18n.yaml b/native/system/packages/entry/README.i18n.yaml new file mode 100644 index 0000000000..6c10cabb1c --- /dev/null +++ b/native/system/packages/entry/README.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 native/system/packages/entry/README.md +README.md: 8f6a4d341c8f9e4c104e5706e08d9a7d45c065e1 +README.zh.md: 152257916df0cf40511b9743d389ae753ab6c85c diff --git a/native/system/packages/entry/README.md b/native/system/packages/entry/README.md new file mode 100644 index 0000000000..8f6a4d341c --- /dev/null +++ b/native/system/packages/entry/README.md @@ -0,0 +1,15 @@ +--- +description: "JavaScript entry for the prebuilt Landlock launcher and asynchronous POSIX flock." +kind: "package-library" +--- +# @deepseek-ai/node-addon-system + +English | [中文](README.zh.md) + +The `./landlock-run` entry exports the Landlock launcher path, enforcement probe, grant arguments, and protocol constants. The independent `./flock` entry exports `tryLockExclusive(fd): Promise`; importing either entry does not load `system.node`. The package has no root export. + +The lock operation attempts `LOCK_EX | LOCK_NB` asynchronously. Keep the caller-owned descriptor open until completion; contention rejects with `EAGAIN`/`EWOULDBLOCK`, other syscall failures also reject, and errors carry their code, positive errno, and `syscall: 'flock'`. Native setup errors reject the same promise. Closing the last descriptor for the open file description releases the lock. The binding does not open, duplicate, close, or explicitly unlock descriptors. + +Optional OS/CPU platform packages carry the binaries. Linux has `bin/landlock-run` and separate `bin/glibc/system.node` / `bin/musl/system.node`; macOS has `bin/system.node`. Missing or unloadable flock bindings reject acquisition, without installation-time compilation. Landlock remains a separate executable with its existing fail-closed protocol; unsupported kernels/platforms probe unusable. + +The two C sources ship for auditability. See the workspace [architecture](../../docs/architecture.md), [support matrix](../../docs/support-matrix.md), and [CLI contract](../../docs/cli-contract.md). diff --git a/native/system/packages/entry/README.zh.md b/native/system/packages/entry/README.zh.md new file mode 100644 index 0000000000..152257916d --- /dev/null +++ b/native/system/packages/entry/README.zh.md @@ -0,0 +1,15 @@ +--- +description: "预编译 Landlock 启动器与异步 POSIX flock 的 JavaScript 入口。" +kind: "package-library" +--- +# @deepseek-ai/node-addon-system + +[English](README.md) | 中文 + +`./landlock-run` 入口导出 Landlock 启动器路径、强制执行探测、授权参数和协议常量。独立的 `./flock` 入口导出 `tryLockExclusive(fd): Promise`;导入任一入口都不会加载 `system.node`。包不提供根导出。 + +锁操作异步尝试 `LOCK_EX | LOCK_NB`。在完成前保持调用方拥有的描述符打开;竞争以 `EAGAIN`/`EWOULDBLOCK` 拒绝,其他系统调用失败也会拒绝,错误携带 code、正 errno 和 `syscall: 'flock'`。原生调用准备阶段的错误也会拒绝同一个 promise。关闭该打开文件描述的最后一个描述符即释放锁。绑定不打开、复制、关闭或显式解锁描述符。 + +可选操作系统/CPU 平台包携带二进制。Linux 包含 `bin/landlock-run` 和分别用于两种 libc 的 `bin/glibc/system.node` / `bin/musl/system.node`;macOS 包含 `bin/system.node`。flock 绑定缺失或无法加载时拒绝获取,不在安装时编译。Landlock 仍是遵循既有失败关闭协议的独立可执行文件;不支持的内核或平台探测为不可用。 + +两个 C 源文件随包分发以供审计。参见工作区[架构](../../docs/architecture.md)、[支持矩阵](../../docs/support-matrix.md)和 [CLI 约定](../../docs/cli-contract.md)。 diff --git a/native/system/packages/entry/package.json b/native/system/packages/entry/package.json new file mode 100644 index 0000000000..5871b01cd5 --- /dev/null +++ b/native/system/packages/entry/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/node-addon-system", + "version": "0.1.2", + "type": "module", + "description": "Prebuilt system primitives: a Linux Landlock launcher and asynchronous POSIX flock through stable Node-API", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "native/system/packages/entry" + }, + "exports": { + "./landlock-run": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./flock": { + "types": "./lib/flock.d.ts", + "default": "./lib/flock.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "README.md", + "lib/", + "!lib/*.tsbuildinfo", + "src/main.c", + "src/flock.c" + ], + "scripts": { + "build:js": "tsc -b", + "prepack": "node ../../scripts/verify-entry-lib.mjs" + }, + "engines": { + "node": ">=20" + }, + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + }, + "optionalDependencies": { + "@deepseek-ai/node-addon-system-darwin-arm64": "workspace:*", + "@deepseek-ai/node-addon-system-darwin-x64": "workspace:*", + "@deepseek-ai/node-addon-system-linux-arm64": "workspace:*", + "@deepseek-ai/node-addon-system-linux-x64": "workspace:*" + } +} diff --git a/native/system/packages/entry/src/flock.c b/native/system/packages/entry/src/flock.c new file mode 100644 index 0000000000..cd5d1f2c1d --- /dev/null +++ b/native/system/packages/entry/src/flock.c @@ -0,0 +1,164 @@ +/* + * Node-API v8 binding for asynchronous flock(LOCK_EX | LOCK_NB). + * The caller owns fd through completion; this module never opens, duplicates, + * closes, or explicitly unlocks it. The callback receives zero or a positive + * errno; JavaScript owns the promise and syscall error construction. + */ + +#include +#include +#include +#include +#include + +typedef struct { + napi_env env; + napi_ref callback; + napi_async_work work; + napi_async_cleanup_hook_handle cleanup; + int fd; + int error; + bool closing; +} lock_request; + +static void check_status(napi_status status, const char *message) { + if (status != napi_ok) { + napi_fatal_error("flock", NAPI_AUTO_LENGTH, message, NAPI_AUTO_LENGTH); + } +} + +static void release_request(lock_request *request) { + if (request->callback != NULL) { + (void)napi_delete_reference(request->env, request->callback); + } + if (request->work != NULL) { + (void)napi_delete_async_work(request->env, request->work); + } + if (request->cleanup != NULL) { + (void)napi_remove_async_cleanup_hook(request->cleanup); + } + free(request); +} + +static napi_value throw_setup_error(napi_env env, napi_status status, + const char *message) { + if (status != napi_pending_exception) { + status = napi_throw_error(env, "ERR_FLOCK_ASYNC_WORK", message); + bool pending; + /* Error construction can fail with both generic_failure and a JS exception. */ + check_status(napi_is_exception_pending(env, &pending), "Cannot inspect flock setup exception"); + if (!pending && status != napi_pending_exception) check_status(status, message); + } + return NULL; +} + +static void execute_lock(napi_env env, void *data) { + (void)env; + lock_request *request = data; + request->error = flock(request->fd, LOCK_EX | LOCK_NB) == 0 ? 0 : errno; +} + +static void complete_lock(napi_env env, napi_status status, void *data) { + lock_request *request = data; + if (env != NULL && !request->closing) { + napi_value callback; + napi_value receiver; + napi_value result; + check_status(status, "flock async work did not complete"); + check_status(napi_get_reference_value(env, request->callback, &callback), + "Cannot retrieve flock callback"); + check_status(napi_get_undefined(env, &receiver), "Cannot create flock receiver"); + check_status(napi_create_int32(env, request->error, &result), + "Cannot create flock result"); + status = napi_call_function(env, receiver, callback, 1, &result, NULL); + } else { + status = napi_ok; + } + release_request(request); + /* Node's async-work dispatcher reports callback exceptions and handles termination. */ + if (status != napi_pending_exception) { + check_status(status, "Cannot invoke flock callback"); + } +} + +static void cleanup_lock(napi_async_cleanup_hook_handle handle, void *data) { + (void)handle; + lock_request *request = data; + request->closing = true; + /* + * Running work cannot be cancelled. The hook keeps the environment alive + * until completion releases both the work and this hook; it never frees + * memory that the execution thread can still access. + */ + (void)napi_cancel_async_work(request->env, request->work); +} + +static napi_value try_lock(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + double fd; + napi_status status = napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + if (status != napi_ok) { + return throw_setup_error(env, status, "Cannot read flock arguments"); + } + if (argc < 1 || napi_get_value_double(env, argv[0], &fd) != napi_ok) { + (void)napi_throw_type_error(env, NULL, "fd must be a number"); + return NULL; + } + if (!(fd >= INT_MIN && fd <= INT_MAX) || fd != (int)fd) { + (void)napi_throw_range_error(env, NULL, "fd must be a signed C int"); + return NULL; + } + napi_valuetype callback_type; + if (argc < 2 || napi_typeof(env, argv[1], &callback_type) != napi_ok || + callback_type != napi_function) { + (void)napi_throw_type_error(env, NULL, "callback must be a function"); + return NULL; + } + + lock_request *request = calloc(1, sizeof(*request)); + if (request == NULL) { + (void)napi_throw_error(env, "ENOMEM", "Cannot allocate flock async work"); + return NULL; + } + request->env = env; + request->fd = (int)fd; + + status = napi_create_reference(env, argv[1], 1, &request->callback); + if (status != napi_ok) { + release_request(request); + return throw_setup_error(env, status, "Cannot retain flock callback"); + } + napi_value name; + status = napi_create_string_utf8(env, "flock", NAPI_AUTO_LENGTH, &name); + if (status == napi_ok) { + status = napi_create_async_work(env, NULL, name, execute_lock, complete_lock, + request, &request->work); + } + if (status != napi_ok) { + release_request(request); + return throw_setup_error(env, status, "Cannot create flock async work"); + } + status = napi_add_async_cleanup_hook(env, cleanup_lock, request, &request->cleanup); + if (status != napi_ok) { + release_request(request); + return throw_setup_error(env, status, "Cannot register flock environment cleanup"); + } + status = napi_queue_async_work(env, request->work); + if (status != napi_ok) { + /* Queue failure schedules no completion callback. */ + release_request(request); + return throw_setup_error(env, status, "Cannot queue flock async work"); + } + return NULL; +} + +NAPI_MODULE_INIT() { + napi_value function; + if (napi_create_function(env, "tryLock", NAPI_AUTO_LENGTH, try_lock, + NULL, &function) != napi_ok || + napi_set_named_property(env, exports, "tryLock", function) != napi_ok) { + return NULL; + } + return exports; +} diff --git a/native/system/packages/entry/src/flock.ts b/native/system/packages/entry/src/flock.ts new file mode 100644 index 0000000000..fcbf8a9e3c --- /dev/null +++ b/native/system/packages/entry/src/flock.ts @@ -0,0 +1,57 @@ +/** Lazy POSIX flock entry; importing it does not load a native addon. */ +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { getSystemErrorName } from 'node:util' + +interface FlockBinding { + tryLock(fd: number, callback: (errno: number) => void): void +} + +let binding: FlockBinding | undefined + +function loadBinding(): FlockBinding { + if (binding) return binding + const { platform, arch } = process + if (platform !== 'linux' && platform !== 'darwin') { + throw Object.assign(new Error(`flock is not supported on ${platform}-${arch}`), { + code: 'ERR_FLOCK_UNSUPPORTED_PLATFORM', + syscall: 'flock', + }) + } + + let filename = 'system.node' + if (platform === 'linux') { + // Node's report types omit the libc field supplied by Linux reports. + const report = process.report.getReport() as { header: { glibcVersionRuntime?: string } } + filename = join(report.header.glibcVersionRuntime ? 'glibc' : 'musl', filename) + } + const require = createRequire(import.meta.url) + const manifest = require.resolve(`@deepseek-ai/node-addon-system-${platform}-${arch}/package.json`) + binding = require(join(dirname(manifest), 'bin', filename)) as FlockBinding + return binding +} + +/** + * Attempt an exclusive, nonblocking POSIX flock on the caller's descriptor. + * The syscall runs in asynchronous work, so acquisition can occur after this + * call returns. Keep fd open until the promise settles; the binding never + * opens, duplicates, or closes it. Closing the locked descriptor releases the + * lock once all descriptors for its open file description are closed. + * @param fd - Open file descriptor to lock; ownership remains with the caller. + * @returns A promise resolving to void on acquisition. Contention rejects with + * EAGAIN/EWOULDBLOCK; other syscall failures also reject. Syscall errors carry + * code, positive errno, and syscall='flock'. Native setup errors, unsupported + * platforms, and addon loading failures reject; importing alone does not load it. + */ +export async function tryLockExclusive(fd: number): Promise { + const errno = await new Promise((resolve) => { + loadBinding().tryLock(fd, resolve) + }) + if (errno === 0) return + const code = getSystemErrorName(-errno) + throw Object.assign(new Error(`${code}: flock failed`), { + code, + errno, + syscall: 'flock', + }) +} diff --git a/native/landlock-run/packages/entry/src/index.ts b/native/system/packages/entry/src/index.ts similarity index 97% rename from native/landlock-run/packages/entry/src/index.ts rename to native/system/packages/entry/src/index.ts index 2bb0699381..5a6c5f955f 100644 --- a/native/landlock-run/packages/entry/src/index.ts +++ b/native/system/packages/entry/src/index.ts @@ -53,7 +53,7 @@ export interface LauncherGrants { /** * Path of the launcher binary for this host: resolved from the per-platform - * npm package `@deepseek-ai/node-addon-landlock-run--` (npm's + * npm package `@deepseek-ai/node-addon-system--` (npm's * `os`/`cpu` fields make installers fetch only the matching one). When the * package is not resolvable — a platform without one, or an install that * skipped the optional dependency — the returned fallback path points inside @@ -69,7 +69,7 @@ export interface LauncherGrants { export function launcherPath( resolvePackageJson: (specifier: string) => string = createRequire(import.meta.url).resolve, ): string { - const platformPackage = `@deepseek-ai/node-addon-landlock-run-${process.platform}-${process.arch}` + const platformPackage = `@deepseek-ai/node-addon-system-${process.platform}-${process.arch}` try { return join(dirname(resolvePackageJson(`${platformPackage}/package.json`)), 'bin', LAUNCHER_BIN) } catch { diff --git a/native/landlock-run/packages/entry/src/main.c b/native/system/packages/entry/src/main.c similarity index 99% rename from native/landlock-run/packages/entry/src/main.c rename to native/system/packages/entry/src/main.c index af0cc2a988..7857030291 100644 --- a/native/landlock-run/packages/entry/src/main.c +++ b/native/system/packages/entry/src/main.c @@ -31,7 +31,7 @@ * linked statically), so the whole audit surface is this file plus the * kernel's stable syscall contract. Built natively per architecture by * `scripts/build.ts` into the per-platform npm packages - * (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar, + * (`@deepseek-ai/node-addon-system-linux-{x64,arm64}`); the argv grammar, * exit codes, and report lines are pinned in `docs/cli-contract.md`. */ diff --git a/native/landlock-run/packages/entry/tsconfig.json b/native/system/packages/entry/tsconfig.json similarity index 100% rename from native/landlock-run/packages/entry/tsconfig.json rename to native/system/packages/entry/tsconfig.json diff --git a/native/system/packages/linux-arm64/LICENSE b/native/system/packages/linux-arm64/LICENSE new file mode 100644 index 0000000000..8187059c9a --- /dev/null +++ b/native/system/packages/linux-arm64/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, node-addon-landlock-run contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/native/system/packages/linux-arm64/README.i18n.yaml b/native/system/packages/linux-arm64/README.i18n.yaml new file mode 100644 index 0000000000..881c7ebaf3 --- /dev/null +++ b/native/system/packages/linux-arm64/README.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 native/system/packages/linux-arm64/README.md +README.md: b1e97481a3f59b5b7cf85e6565cd3d078e42cd7f +README.zh.md: 79f3b75e16570c4dff8d7249bcd75481f11cc85c diff --git a/native/system/packages/linux-arm64/README.md b/native/system/packages/linux-arm64/README.md new file mode 100644 index 0000000000..b1e97481a3 --- /dev/null +++ b/native/system/packages/linux-arm64/README.md @@ -0,0 +1,11 @@ +--- +description: "Prebuilt Landlock launcher and POSIX flock addons for Linux arm64." +kind: "package-library" +--- +# @deepseek-ai/node-addon-system-linux-arm64 + +English | [中文](README.zh.md) + +This platform package contains the static musl executable `bin/landlock-run` and Node-API v8 addons `bin/glibc/system.node` and `bin/musl/system.node`. The entry chooses the addon matching the running Node process's libc; the Landlock executable serves both libc systems. + +The package contains no JavaScript or installation build script. Platform prepack checks complete payloads, ELF architecture, Node-API exports, and launcher executability; the installed-artifact rehearsal checks bytes and executes native behavior. See the workspace [support matrix](../../docs/support-matrix.md). diff --git a/native/system/packages/linux-arm64/README.zh.md b/native/system/packages/linux-arm64/README.zh.md new file mode 100644 index 0000000000..79f3b75e16 --- /dev/null +++ b/native/system/packages/linux-arm64/README.zh.md @@ -0,0 +1,11 @@ +--- +description: "为 Linux arm64 提供预编译 Landlock 启动器和 POSIX flock addon。" +kind: "package-library" +--- +# @deepseek-ai/node-addon-system-linux-arm64 + +[English](README.md) | 中文 + +此平台包包含静态 musl 可执行文件 `bin/landlock-run`,以及 Node-API v8 addon `bin/glibc/system.node` 和 `bin/musl/system.node`。入口包按运行 Node 进程的 libc 选择 addon;Landlock 可执行文件在两种 libc 系统上共用。 + +包中没有 JavaScript 或安装编译脚本。平台 prepack 检查完整产物、ELF 架构、Node-API 导出和启动器可执行权限;安装演练核对字节并执行原生行为。参见工作区[支持矩阵](../../docs/support-matrix.md)。 diff --git a/native/landlock-run/packages/linux-arm64/package.json b/native/system/packages/linux-arm64/package.json similarity index 57% rename from native/landlock-run/packages/linux-arm64/package.json rename to native/system/packages/linux-arm64/package.json index 8e6affec6a..d7fd871b8d 100644 --- a/native/landlock-run/packages/linux-arm64/package.json +++ b/native/system/packages/linux-arm64/package.json @@ -1,11 +1,11 @@ { - "name": "@deepseek-ai/node-addon-landlock-run-linux-arm64", - "version": "0.1.1", - "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by @deepseek-ai/node-addon-landlock-run, never imported", + "name": "@deepseek-ai/node-addon-system-linux-arm64", + "version": "0.1.2", + "description": "Linux arm64 system binaries: static Landlock launcher and glibc/musl Node-API flock addons", "repository": { "type": "git", "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", - "directory": "native/landlock-run/packages/linux-arm64" + "directory": "native/system/packages/linux-arm64" }, "os": [ "linux" diff --git a/native/system/packages/linux-arm64/prebuilds.json b/native/system/packages/linux-arm64/prebuilds.json new file mode 100644 index 0000000000..aef7abf975 --- /dev/null +++ b/native/system/packages/linux-arm64/prebuilds.json @@ -0,0 +1,24 @@ +{ + "platform": "linux-arm64", + "binaries": [ + { + "tool": "landlock-run", + "kind": "static-musl", + "path": "bin/landlock-run" + }, + { + "tool": "flock", + "kind": "node-api", + "napi": 8, + "libc": "glibc", + "path": "bin/glibc/system.node" + }, + { + "tool": "flock", + "kind": "node-api", + "napi": 8, + "libc": "musl", + "path": "bin/musl/system.node" + } + ] +} diff --git a/native/system/packages/linux-x64/LICENSE b/native/system/packages/linux-x64/LICENSE new file mode 100644 index 0000000000..8187059c9a --- /dev/null +++ b/native/system/packages/linux-x64/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, node-addon-landlock-run contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/native/system/packages/linux-x64/README.i18n.yaml b/native/system/packages/linux-x64/README.i18n.yaml new file mode 100644 index 0000000000..631328590a --- /dev/null +++ b/native/system/packages/linux-x64/README.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 native/system/packages/linux-x64/README.md +README.md: caebb5d8a888a69a952fc89afb3edfb8ece489fc +README.zh.md: 6ad1dcf6fe26007f76cdeb98936a94262ece4d7c diff --git a/native/system/packages/linux-x64/README.md b/native/system/packages/linux-x64/README.md new file mode 100644 index 0000000000..caebb5d8a8 --- /dev/null +++ b/native/system/packages/linux-x64/README.md @@ -0,0 +1,11 @@ +--- +description: "Prebuilt Landlock launcher and POSIX flock addons for Linux x64." +kind: "package-library" +--- +# @deepseek-ai/node-addon-system-linux-x64 + +English | [中文](README.zh.md) + +This platform package contains the static musl executable `bin/landlock-run` and Node-API v8 addons `bin/glibc/system.node` and `bin/musl/system.node`. The entry chooses the addon matching the running Node process's libc; the Landlock executable serves both libc systems. + +The package contains no JavaScript or installation build script. Platform prepack checks complete payloads, ELF architecture, Node-API exports, and launcher executability; the installed-artifact rehearsal checks bytes and executes native behavior. See the workspace [support matrix](../../docs/support-matrix.md). diff --git a/native/system/packages/linux-x64/README.zh.md b/native/system/packages/linux-x64/README.zh.md new file mode 100644 index 0000000000..6ad1dcf6fe --- /dev/null +++ b/native/system/packages/linux-x64/README.zh.md @@ -0,0 +1,11 @@ +--- +description: "为 Linux x64 提供预编译 Landlock 启动器和 POSIX flock addon。" +kind: "package-library" +--- +# @deepseek-ai/node-addon-system-linux-x64 + +[English](README.md) | 中文 + +此平台包包含静态 musl 可执行文件 `bin/landlock-run`,以及 Node-API v8 addon `bin/glibc/system.node` 和 `bin/musl/system.node`。入口包按运行 Node 进程的 libc 选择 addon;Landlock 可执行文件在两种 libc 系统上共用。 + +包中没有 JavaScript 或安装编译脚本。平台 prepack 检查完整产物、ELF 架构、Node-API 导出和启动器可执行权限;安装演练核对字节并执行原生行为。参见工作区[支持矩阵](../../docs/support-matrix.md)。 diff --git a/native/landlock-run/packages/linux-x64/package.json b/native/system/packages/linux-x64/package.json similarity index 58% rename from native/landlock-run/packages/linux-x64/package.json rename to native/system/packages/linux-x64/package.json index 7e9cab7a1a..83d1c4f608 100644 --- a/native/landlock-run/packages/linux-x64/package.json +++ b/native/system/packages/linux-x64/package.json @@ -1,11 +1,11 @@ { - "name": "@deepseek-ai/node-addon-landlock-run-linux-x64", - "version": "0.1.1", - "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by @deepseek-ai/node-addon-landlock-run, never imported", + "name": "@deepseek-ai/node-addon-system-linux-x64", + "version": "0.1.2", + "description": "Linux x64 system binaries: static Landlock launcher and glibc/musl Node-API flock addons", "repository": { "type": "git", "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", - "directory": "native/landlock-run/packages/linux-x64" + "directory": "native/system/packages/linux-x64" }, "os": [ "linux" diff --git a/native/system/packages/linux-x64/prebuilds.json b/native/system/packages/linux-x64/prebuilds.json new file mode 100644 index 0000000000..9408c7d430 --- /dev/null +++ b/native/system/packages/linux-x64/prebuilds.json @@ -0,0 +1,24 @@ +{ + "platform": "linux-x64", + "binaries": [ + { + "tool": "landlock-run", + "kind": "static-musl", + "path": "bin/landlock-run" + }, + { + "tool": "flock", + "kind": "node-api", + "napi": 8, + "libc": "glibc", + "path": "bin/glibc/system.node" + }, + { + "tool": "flock", + "kind": "node-api", + "napi": 8, + "libc": "musl", + "path": "bin/musl/system.node" + } + ] +} diff --git a/native/landlock-run/scripts/assemble-prebuilds.mjs b/native/system/scripts/assemble-prebuilds.mjs similarity index 82% rename from native/landlock-run/scripts/assemble-prebuilds.mjs rename to native/system/scripts/assemble-prebuilds.mjs index 4dcdb23bed..1be3be74d2 100644 --- a/native/landlock-run/scripts/assemble-prebuilds.mjs +++ b/native/system/scripts/assemble-prebuilds.mjs @@ -4,7 +4,7 @@ * verify the result. The Release workflow's build legs upload one * `prebuild-` artifact per platform package (its `bin/` payload); * this script copies each into `packages//bin/` and then checks - * every declared binary for presence and ELF architecture. + * every declared binary for presence and native architecture. * * Usage: `node scripts/assemble-prebuilds.mjs `. */ @@ -39,13 +39,16 @@ for (const artifactName of fs.readdirSync(artifactRoot)) { for (const file of fs.readdirSync(artifactDir)) { const source = path.join(artifactDir, file); const destination = path.join(root, 'packages', name, 'bin', file); - fs.copyFileSync(source, destination); - fs.chmodSync(destination, 0o755); + fs.cpSync(source, destination, { recursive: true, preserveTimestamps: true }); console.log(`Copied ${path.relative(root, source)} -> ${path.relative(root, destination)}`); } } for (const dir of platformDirs()) { + const metadata = JSON.parse(fs.readFileSync(path.join(root, dir, 'prebuilds.json'), 'utf8')); + for (const binary of metadata.binaries) { + if (binary.kind === 'static-musl') fs.chmodSync(path.join(root, dir, binary.path), 0o755); + } const { name, count } = verifyPlatformBinaries(path.join(root, dir)); console.log(`Verified ${name}: ${count} binaries`); } diff --git a/native/system/scripts/build-test-oracle.mjs b/native/system/scripts/build-test-oracle.mjs new file mode 100644 index 0000000000..22f6bf9f4b --- /dev/null +++ b/native/system/scripts/build-test-oracle.mjs @@ -0,0 +1,23 @@ +/** Build the independent POSIX flock oracle used by native behavior tests. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +if (process.platform !== 'linux' && process.platform !== 'darwin') { + throw new Error('The flock oracle is a POSIX test fixture'); +} +const variants = process.platform === 'linux' ? ['glibc', 'musl'] : ['']; +for (const variant of variants) { + const compiler = variant === 'musl' ? 'musl-gcc' : 'cc'; + const output = path.join(root, 'test/bin', variant, 'flock-oracle'); + fs.mkdirSync(path.dirname(output), { recursive: true }); + const args = ['-std=c11', '-O2', '-Wall', '-Wextra', '-Werror']; + if (process.platform === 'darwin') args.push('-mmacosx-version-min=11.0'); + if (variant === 'musl') args.push('-static'); + const result = spawnSync(compiler, [...args, path.join(root, 'test/fixtures/flock-oracle.c'), '-o', output], { stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${compiler} failed to build the flock oracle`); + console.log(`Built test oracle ${path.relative(root, output)}`); +} diff --git a/native/system/scripts/build.ts b/native/system/scripts/build.ts new file mode 100644 index 0000000000..ca995d9c03 --- /dev/null +++ b/native/system/scripts/build.ts @@ -0,0 +1,92 @@ +/** + * Build this host's declared system binaries. Landlock is a static musl + * executable; flock uses stable Node-API with separate Linux libc builds. + * Node headers come from the Node installation running this script. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { parseArgs } from 'node:util' + +const root = resolve(import.meta.dirname, '..') +const { values } = parseArgs({ options: { 'host-addon-only': { type: 'boolean' } }, allowPositionals: false }) +const hostAddonOnly = values['host-addon-only'] === true +const sources: Record = { + 'landlock-run': 'packages/entry/src/main.c', + flock: 'packages/entry/src/flock.c', +} + +interface Binary { + tool: string + kind: string + path: string + napi?: number + libc?: string +} + +if (process.platform !== 'linux' && process.platform !== 'darwin') { + if (hostAddonOnly) process.exit(0) + throw new Error('build: system binaries are built on Linux or macOS; no native target for this host') +} +const host = `${process.platform}-${process.arch}` +const libc = process.platform === 'linux' + ? ((process.report.getReport() as { header: { glibcVersionRuntime?: string } }).header.glibcVersionRuntime ? 'glibc' : 'musl') + : undefined +const headers = resolve(dirname(process.execPath), '../include/node') +let built = 0 + +for (const name of readdirSync(join(root, 'packages')).sort()) { + const dir = join(root, 'packages', name) + const metadata = join(dir, 'prebuilds.json') + if (!existsSync(metadata)) continue + const spec = JSON.parse(readFileSync(metadata, 'utf8')) as { platform: string; binaries: Binary[] } + if (spec.platform !== host) continue + + for (const binary of spec.binaries) { + if (hostAddonOnly && (binary.kind !== 'node-api' || (binary.libc !== undefined && binary.libc !== libc))) continue + const source = sources[binary.tool] + if (source === undefined) throw new Error(`build: unknown tool ${binary.tool}`) + const output = join(dir, binary.path) + mkdirSync(dirname(output), { recursive: true }) + let compiler: string + let flags: string[] + + if (binary.kind === 'static-musl' && process.platform === 'linux' && binary.tool === 'landlock-run') { + compiler = 'musl-gcc' + flags = ['-std=c11', '-Os', '-Wall', '-Wextra', '-Werror', '-static', '-s'] + } else if (binary.kind === 'node-api' && binary.tool === 'flock' && binary.napi === 8) { + if (!existsSync(join(headers, 'node_api.h'))) { + throw new Error(`build: Node-API headers missing at ${headers}; use a Node installation with development headers`) + } + compiler = process.platform === 'linux' && binary.libc === 'musl' ? 'musl-gcc' : 'cc' + flags = ['-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', '-fPIC', '-fvisibility=hidden', '-DNAPI_VERSION=8', '-I', headers] + if (process.platform === 'darwin') { + if (binary.libc !== undefined) throw new Error('build: macOS flock does not select a Linux libc') + flags.push('-bundle', '-undefined', 'dynamic_lookup', '-mmacosx-version-min=11.0') + } else { + if (binary.libc !== 'glibc' && binary.libc !== 'musl') { + throw new Error('build: Linux flock must select glibc or musl') + } + flags.push('-shared') + } + } else { + throw new Error(`build: unsupported ${binary.tool}/${binary.kind} target on ${host}`) + } + + mkdirSync(join(root, '.release'), { recursive: true }) + const temporary = mkdtempSync(join(root, '.release', 'native-build-')) + try { + const pending = join(temporary, basename(output)) + const result = spawnSync(compiler, [...flags, '-o', pending, join(root, source)], { stdio: 'inherit' }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`build: ${compiler} failed for ${binary.path}`) + // Readers never see a truncated addon when source checks build concurrently. + renameSync(pending, output) + } finally { + rmSync(temporary, { recursive: true, force: true }) + } + console.log(`build: built ${basename(dir)}/${binary.path}`) + built++ + } +} +if (built === 0) throw new Error(`build: no declared binaries for ${host}`) diff --git a/native/landlock-run/scripts/bump-release.mjs b/native/system/scripts/bump-release.mjs similarity index 100% rename from native/landlock-run/scripts/bump-release.mjs rename to native/system/scripts/bump-release.mjs diff --git a/native/landlock-run/scripts/commit-release.mjs b/native/system/scripts/commit-release.mjs similarity index 90% rename from native/landlock-run/scripts/commit-release.mjs rename to native/system/scripts/commit-release.mjs index 1b1c78bce5..e2e2dac691 100644 --- a/native/landlock-run/scripts/commit-release.mjs +++ b/native/system/scripts/commit-release.mjs @@ -37,6 +37,6 @@ run('git', [ 'packages/*/package.json', '../../pnpm-lock.yaml', ]); -run('git', ['commit', '-m', `release(landlock-run): ${version}`]); +run('git', ['commit', '-m', `release(node-addon-system): ${version}`]); -console.log(`Committed release ${version}. Create the tag manually: git tag landlock-run-v${version}`); +console.log(`Committed release ${version}. Create the tag manually: git tag node-addon-system-v${version}`); diff --git a/native/landlock-run/scripts/github-matrix.mjs b/native/system/scripts/github-matrix.mjs similarity index 91% rename from native/landlock-run/scripts/github-matrix.mjs rename to native/system/scripts/github-matrix.mjs index 9566b89c8a..312d2fea93 100644 --- a/native/landlock-run/scripts/github-matrix.mjs +++ b/native/system/scripts/github-matrix.mjs @@ -15,6 +15,8 @@ import { platformDirs, readJson, root } from './repo.mjs'; const RUNNERS = { 'linux-x64': 'ubuntu-24.04', 'linux-arm64': 'ubuntu-24.04-arm', + 'darwin-x64': 'macos-15-intel', + 'darwin-arm64': 'macos-latest', }; function runnerFor(platform) { @@ -56,6 +58,7 @@ const target = process.argv[2]; const matrices = { ci: ciMatrix, 'release-prebuild': releasePrebuildMatrix, + compatibility: () => ciMatrix().include.flatMap((row) => [20, 22, 24, 26].map((node) => ({ ...row, node }))), }; if (!target || !matrices[target]) { diff --git a/native/landlock-run/scripts/pack-release.mjs b/native/system/scripts/pack-release.mjs similarity index 100% rename from native/landlock-run/scripts/pack-release.mjs rename to native/system/scripts/pack-release.mjs diff --git a/native/landlock-run/scripts/publish-release.mjs b/native/system/scripts/publish-release.mjs similarity index 100% rename from native/landlock-run/scripts/publish-release.mjs rename to native/system/scripts/publish-release.mjs diff --git a/native/system/scripts/repo.mjs b/native/system/scripts/repo.mjs new file mode 100644 index 0000000000..ceb1de6f1d --- /dev/null +++ b/native/system/scripts/repo.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * Shared helpers for the repo scripts: package discovery, the checked-in + * prebuild matrix, and binary verification. The package matrix is explicit + * metadata — `packages//prebuilds.json` marks a platform package and + * declares its binaries; everything else under `packages/` is an entry + * package. Scripts derive from these files and never guess. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const root = fileURLToPath(new URL('..', import.meta.url)); +const packagesRoot = path.join(root, 'packages'); + +/** ELF `e_machine` (offset 18, little-endian) per platform-package `cpu` value. */ +const E_MACHINE = { x64: 62, arm64: 183 }; + +export function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +/** Platform packages: every `packages/` carrying a `prebuilds.json`. */ +export function platformDirs() { + return fs.readdirSync(packagesRoot) + .filter((name) => fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json'))) + .sort() + .map((name) => path.join('packages', name)); +} + +/** Entry packages: every other `packages/` with a `package.json`. */ +export function entryDirs() { + return fs.readdirSync(packagesRoot) + .filter((name) => !fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json'))) + .filter((name) => fs.existsSync(path.join(packagesRoot, name, 'package.json'))) + .sort() + .map((name) => path.join('packages', name)); +} + +/** All published packages in publish order: platform packages before the entries that optionally depend on them. */ +export function packageDirs() { + return [...platformDirs(), ...entryDirs()]; +} + +/** + * Verify platform metadata, complete bin/ payloads, executable permissions, + * and native file formats before packing. Node addons must export Node-API. + */ +export function verifyPlatformBinaries(packageDir) { + const manifest = readJson(path.join(packageDir, 'package.json')); + const prebuilds = readJson(path.join(packageDir, 'prebuilds.json')); + const cpu = manifest.cpu?.[0]; + const os = manifest.os?.[0]; + if (!(cpu in E_MACHINE) || !['linux', 'darwin'].includes(os)) { + throw new Error(`${manifest.name}: unsupported or missing os/cpu metadata`); + } + if (prebuilds.platform !== `${os}-${cpu}`) { + throw new Error(`${manifest.name}: prebuild platform disagrees with package os/cpu`); + } + + const declared = new Set(); + for (const binary of prebuilds.binaries) { + if (typeof binary.path !== 'string' || !/^bin\/(?:[a-z0-9-]+\/)?[a-z0-9._-]+$/.test(binary.path)) { + throw new Error(`${manifest.name}: binary path must name a file inside bin/`); + } + if (declared.has(binary.path)) throw new Error(`${manifest.name}: duplicate binary path ${binary.path}`); + declared.add(binary.path); + const executable = binary.kind === 'static-musl' && binary.tool === 'landlock-run' && os === 'linux'; + const addon = binary.kind === 'node-api' && binary.tool === 'flock' && binary.napi === 8; + if (!executable && !addon) throw new Error(`${manifest.name}: unsupported binary kind/tool/NAPI for ${binary.path}`); + if (addon && os === 'linux' && !['glibc', 'musl'].includes(binary.libc)) { + throw new Error(`${manifest.name}: Linux addon must declare glibc or musl`); + } + if (addon && os === 'darwin' && binary.libc !== undefined) { + throw new Error(`${manifest.name}: macOS addon must not declare a Linux libc`); + } + + const file = path.join(packageDir, binary.path); + if (!fs.existsSync(file)) throw new Error(`${manifest.name}: missing ${binary.path} — build this platform before packing`); + if (!fs.lstatSync(file).isFile()) throw new Error(`${manifest.name}: ${binary.path} is not a regular file`); + if (executable) { + try { fs.accessSync(file, fs.constants.X_OK); } + catch { throw new Error(`${manifest.name}: ${binary.path} is not executable`); } + } + const data = fs.readFileSync(file); + if (os === 'linux') { + if (data.length < 64 || data.readUInt32LE(0) !== 0x464c457f || data[4] !== 2 || data[5] !== 1) { + throw new Error(`${manifest.name}: ${binary.path} is not a little-endian ELF64 binary`); + } + if (data.readUInt16LE(18) !== E_MACHINE[cpu]) { + throw new Error(`${manifest.name}: ${binary.path} has the wrong ELF architecture`); + } + if (data.readUInt16LE(16) !== (executable ? 2 : 3)) { + throw new Error(`${manifest.name}: ${binary.path} has the wrong ELF file type`); + } + } else { + const expectedCpu = cpu === 'x64' ? 0x01000007 : 0x0100000c; + if (data.length < 32 || data.readUInt32LE(0) !== 0xfeedfacf) { + throw new Error(`${manifest.name}: ${binary.path} is not a Mach-O 64-bit bundle`); + } + if (data.readUInt32LE(4) !== expectedCpu || data.readUInt32LE(12) !== 8) { + throw new Error(`${manifest.name}: ${binary.path} has the wrong Mach-O architecture or file type`); + } + } + if (addon && (!data.includes(Buffer.from('napi_register_module_v1')) + || !data.includes(Buffer.from('node_api_module_get_api_version_v1')))) { + throw new Error(`${manifest.name}: ${binary.path} does not export the Node-API entry points`); + } + } + + function files(dir, prefix) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const name = prefix + '/' + entry.name; + return entry.isDirectory() ? files(path.join(dir, entry.name), name) : [name]; + }); + } + const extra = files(path.join(packageDir, 'bin'), 'bin').filter((name) => !declared.has(name)); + if (extra.length) throw new Error(`${manifest.name}: undeclared bin/ files: ${extra.join(', ')}`); + return { name: manifest.name, count: declared.size }; +} diff --git a/native/landlock-run/scripts/verify-entry-lib.mjs b/native/system/scripts/verify-entry-lib.mjs similarity index 78% rename from native/landlock-run/scripts/verify-entry-lib.mjs rename to native/system/scripts/verify-entry-lib.mjs index 214705e2bc..2fc21eae1e 100644 --- a/native/landlock-run/scripts/verify-entry-lib.mjs +++ b/native/system/scripts/verify-entry-lib.mjs @@ -16,7 +16,10 @@ import path from 'node:path'; const packageDir = process.cwd(); const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')); -for (const file of ['lib/index.js', 'lib/index.d.ts']) { +const exportedFiles = Object.values(manifest.exports) + .flatMap((entry) => typeof entry === 'string' ? [entry] : Object.values(entry)) + .filter((file) => typeof file === 'string' && file.startsWith('./lib/')); +for (const file of exportedFiles) { if (!fs.existsSync(path.join(packageDir, file))) { console.error(`verify-entry-lib: ${manifest.name} has no ${file} — run \`pnpm build:ts\` before packing.`); process.exit(1); diff --git a/native/landlock-run/scripts/verify-launcher-binary.mjs b/native/system/scripts/verify-launcher-binary.mjs similarity index 84% rename from native/landlock-run/scripts/verify-launcher-binary.mjs rename to native/system/scripts/verify-launcher-binary.mjs index 997b20ca0f..ae89102b16 100644 --- a/native/landlock-run/scripts/verify-launcher-binary.mjs +++ b/native/system/scripts/verify-launcher-binary.mjs @@ -7,8 +7,8 @@ * `pnpm run build:native` would ship an EMPTY platform package — the * binary's absence surfacing only at runtime as a failed probe on every * consumer — and a binary copied across packages would advertise an - * architecture it cannot execute. The check is presence + ELF `e_machine` - * against the package's declared `cpu`. `verify-packed-install.mjs` + * architecture it cannot execute. Checks cover ELF/Mach-O format, architecture, + * declared payloads, and Node-API exports. `verify-packed-install.mjs` * separately pins the installed tarball bytes to the workspace build. * * Runs from each platform package's `prepack` hook (pnpm sets the script @@ -23,7 +23,7 @@ const packageDir = process.argv[2] ? path.resolve(root, process.argv[2]) : proce try { const { name, count } = verifyPlatformBinaries(packageDir); - console.log(`verify-launcher-binary: ${name} — ${count} binaries present with the right ELF architecture.`); + console.log(`verify-launcher-binary: ${name} — ${count} binaries present with the right native format and architecture.`); } catch (error) { console.error(`verify-launcher-binary: ${error instanceof Error ? error.message : error}`); process.exit(1); diff --git a/native/landlock-run/scripts/verify-packed-install.mjs b/native/system/scripts/verify-packed-install.mjs similarity index 80% rename from native/landlock-run/scripts/verify-packed-install.mjs rename to native/system/scripts/verify-packed-install.mjs index 928fff50f7..ebd6df487d 100644 --- a/native/landlock-run/scripts/verify-packed-install.mjs +++ b/native/system/scripts/verify-packed-install.mjs @@ -4,15 +4,14 @@ * exactly what a consumer install needs. `pnpm pack` already produced the * bytes `pnpm publish` would upload; this script checks the payload * (coverage, concrete dependency versions, NO lifecycle install scripts — - * this family has no install fallback on purpose), unpacks the entry plus + * this family has no install fallback on purpose), installs the entry plus * THIS host's platform tarball into a throwaway consumer OUTSIDE the repo, * byte-pins the installed binary against the workspace build it was packed * from, and drives the INSTALLED entry under plain `node` — resolution, * probe, and a real confinement world-proof through the installed launcher. * - * On non-Linux hosts (no platform package exists) it instead proves the - * documented degradation: resolution falls back to a nonexistent path and - * the probe reports `unusable`. + * On non-Linux hosts it proves that Landlock remains unavailable, while + * supported POSIX hosts independently exercise the flock binding. * * Usage: `node scripts/verify-packed-install.mjs [tarball-dir] [--current-platform-only]`. * The flag skips the all-platforms tarball-presence check for @@ -31,7 +30,7 @@ import { entryDirs, packageDirs, platformDirs, readJson, root } from './repo.mjs const args = process.argv.slice(2); const currentPlatformOnly = args.includes('--current-platform-only'); const tarballDir = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm')); -const entryPackageName = '@deepseek-ai/node-addon-landlock-run'; +const entryPackageName = '@deepseek-ai/node-addon-system'; function tarballName(manifest) { if (manifest.name.startsWith('@')) { @@ -56,7 +55,7 @@ function run(command, commandArgs, options = {}) { }); if (result.error) throw result.error; if (result.status !== 0) { - process.exit(result.status ?? 1); + throw new Error(`${command} failed (status=${result.status}, signal=${result.signal})`); } } @@ -98,19 +97,6 @@ function packageInstallDir(packageName) { return path.join(tempRoot, 'node_modules', ...packageName.split('/')); } -function unpackTarball(manifest) { - const extractRoot = fs.mkdtempSync(path.join(tempRoot, 'extract-')); - run('tar', ['-xzf', tarballPath(manifest), '-C', extractRoot]); - - const source = path.join(extractRoot, 'package'); - const destination = packageInstallDir(manifest.name); - fs.rmSync(destination, { recursive: true, force: true }); - fs.mkdirSync(path.dirname(destination), { recursive: true }); - fs.renameSync(source, destination); - fs.rmSync(extractRoot, { recursive: true, force: true }); - console.log(`Unpacked ${manifest.name} -> ${path.relative(tempRoot, destination)}`); -} - const manifests = packageDirs().map((dir) => ({ dir, manifest: readJson(path.join(root, dir, 'package.json')) })); const entryManifest = manifests.find(({ manifest }) => manifest.name === entryPackageName)?.manifest; if (!entryManifest) throw new Error(`missing source manifest for ${entryPackageName}`); @@ -144,16 +130,16 @@ for (const { manifest } of expectedTarballs) { } // Throwaway ESM consumer, built from local tarballs only — no registry. -const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-packed-install-')); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'native-system-packed-')); +try { fs.writeFileSync( path.join(tempRoot, 'package.json'), - `${JSON.stringify({ name: 'nalr-packed-install-check', version: '0.0.0', private: true, type: 'module' }, null, 2)}\n`, + `${JSON.stringify({ name: 'native-system-packed-check', version: '0.0.0', private: true, type: 'module', dependencies: Object.fromEntries([entryManifest, ...(currentPlatformEntry ? [currentPlatformEntry.manifest] : [])].map((manifest) => [manifest.name, `file:${tarballPath(manifest)}`])) }, null, 2)}\n`, ); console.log(`Verifying packed install in ${tempRoot}`); -unpackTarball(entryManifest); +run('npm', ['install', '--offline', '--no-audit', '--no-fund', '--package-lock=false'], { cwd: tempRoot }); if (currentPlatformEntry) { - unpackTarball(currentPlatformEntry.manifest); // Byte-pin: the installed binary must be the workspace build it was packed // from — any divergence means the tarball did not carry the built bytes. @@ -180,10 +166,15 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-landlock-run'; +import { grantArgs, launcherPath, probe } from '@deepseek-ai/node-addon-system/landlock-run'; +import { tryLockExclusive } from '@deepseek-ai/node-addon-system/flock'; + +await assert.rejects(import('@deepseek-ai/node-addon-system'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', +}); const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1'; -const platformPackage = '@deepseek-ai/node-addon-landlock-run-' + process.platform + '-' + process.arch; +const platformPackage = '@deepseek-ai/node-addon-system-' + process.platform + '-' + process.arch; const resolved = launcherPath(); assert.ok(path.isAbsolute(resolved), 'launcherPath must be absolute'); assert.ok(resolved.includes(path.join(...platformPackage.split('/'))), 'launcherPath must point into the platform package: ' + resolved); @@ -213,11 +204,35 @@ if (process.platform === 'linux') { console.log('confinement world-proof passed through the installed launcher'); } } else { - assert.ok(!fs.existsSync(resolved), 'no platform package exists for this host — the fallback path must not exist'); + assert.ok(!fs.existsSync(resolved), 'Landlock has no executable for this host'); assert.equal(probe(resolved), 'unusable'); console.log('non-linux host: fallback resolution and unusable probe verified'); } + +if (process.platform === 'linux' || process.platform === 'darwin') { + const lockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'native-system-flock-')); + const handles = []; + try { + const lock = path.join(lockRoot, 'lock'); + const a = fs.openSync(lock, 'wx+', 0o600); + handles.push(a); + const b = fs.openSync(lock, 'r+'); + handles.push(b); + await tryLockExclusive(a); + await assert.rejects(tryLockExclusive(b), { code: 'EAGAIN' }); + fs.closeSync(a); + handles.splice(handles.indexOf(a), 1); + await tryLockExclusive(b); + console.log('installed Node-API flock: exclusion and close release verified'); + } finally { + for (const fd of handles) fs.closeSync(fd); + fs.rmSync(lockRoot, { recursive: true, force: true }); + } +} `); run(process.execPath, [driver], { cwd: tempRoot }); console.log('Packed install verification passed.'); +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/native/landlock-run/scripts/verify-release.mjs b/native/system/scripts/verify-release.mjs similarity index 80% rename from native/landlock-run/scripts/verify-release.mjs rename to native/system/scripts/verify-release.mjs index ff3917b4b5..f09faa7bd9 100644 --- a/native/landlock-run/scripts/verify-release.mjs +++ b/native/system/scripts/verify-release.mjs @@ -2,15 +2,15 @@ /** * Release verification. Always: every published package carries one shared * version, and — when running from a tag or publishing — the - * `landlock-run-vX.Y.Z` tag matches it. With `--prebuilds`: every platform package's declared - * binaries exist with the right ELF architecture (run after + * `node-addon-system-vX.Y.Z` tag matches it. With `--prebuilds`: every platform package's declared + * binaries exist with the right native format and architecture (run after * `assemble-prebuilds.mjs` or a local `build:native`). */ import path from 'node:path'; import { packageDirs, platformDirs, readJson, root, verifyPlatformBinaries } from './repo.mjs'; -const TAG_PREFIX = 'refs/tags/landlock-run-v'; +const TAG_PREFIX = 'refs/tags/node-addon-system-v'; function verifyVersions() { const packages = packageDirs().map((dir) => ({ @@ -29,12 +29,12 @@ function verifyVersions() { const ref = process.env.GITHUB_REF || ''; const publish = process.env.RELEASE_PUBLISH === 'true'; if (publish && !ref.startsWith(TAG_PREFIX)) { - throw new Error('publishing requires running the workflow from a landlock-run-v* tag'); + throw new Error('publishing requires running the workflow from a node-addon-system-v* tag'); } if (ref.startsWith(TAG_PREFIX)) { const tagVersion = ref.slice(TAG_PREFIX.length); if (tagVersion !== version) { - throw new Error(`tag/version mismatch: tag landlock-run-v${tagVersion}, packages ${version}`); + throw new Error(`tag/version mismatch: tag node-addon-system-v${tagVersion}, packages ${version}`); } } diff --git a/native/landlock-run/test/entry.test.js b/native/system/test/entry.test.js similarity index 92% rename from native/landlock-run/test/entry.test.js rename to native/system/test/entry.test.js index 76e2e4321a..b93bc2a6eb 100644 --- a/native/landlock-run/test/entry.test.js +++ b/native/system/test/entry.test.js @@ -15,7 +15,11 @@ import { grantArgs, launcherPath, probe, -} from '@deepseek-ai/node-addon-landlock-run'; +} from '@deepseek-ai/node-addon-system/landlock-run'; + +await assert.rejects(import('@deepseek-ai/node-addon-system'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', +}); // --- constants are part of the CLI contract --- assert.equal(LAUNCHER_BIN, 'landlock-run'); @@ -31,7 +35,7 @@ assert.deepEqual( assert.deepEqual(grantArgs({ readWrite: ['/a'], readOnly: ['/b'] }), ['--ro', '/b', '--rw', '/a']); // --- launcherPath: resolves the platform package next to its package.json --- -const platformPackage = `@deepseek-ai/node-addon-landlock-run-${process.platform}-${process.arch}`; +const platformPackage = `@deepseek-ai/node-addon-system-${process.platform}-${process.arch}`; const resolvedViaSeam = launcherPath((specifier) => { assert.equal(specifier, `${platformPackage}/package.json`); return path.join('/fake-install', specifier); diff --git a/native/system/test/fixtures/flock-binding.js b/native/system/test/fixtures/flock-binding.js new file mode 100644 index 0000000000..ee2d00d052 --- /dev/null +++ b/native/system/test/fixtures/flock-binding.js @@ -0,0 +1,12 @@ +/** Load the private callback API to test native completion independently of its Promise wrapper. */ +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +/** @returns The built addon's private callback binding for this host. */ +export function loadFlockBinding() { + const libc = process.platform === 'linux' + ? `${process.report.getReport().header.glibcVersionRuntime ? 'glibc' : 'musl'}/` + : ''; + const binary = new URL(`../../packages/${process.platform}-${process.arch}/bin/${libc}system.node`, import.meta.url); + return createRequire(import.meta.url)(fileURLToPath(binary)); +} diff --git a/native/system/test/fixtures/flock-callback-throws.js b/native/system/test/fixtures/flock-callback-throws.js new file mode 100644 index 0000000000..f1039b8895 --- /dev/null +++ b/native/system/test/fixtures/flock-callback-throws.js @@ -0,0 +1,6 @@ +/** Isolate an uncaught native-callback exception from the test runner. */ +import { loadFlockBinding } from './flock-binding.js'; + +const binding = loadFlockBinding(); +process.send({ type: 'ready' }); +binding.tryLock(-1, () => { throw new Error('flock callback failure'); }); diff --git a/native/system/test/fixtures/flock-child.js b/native/system/test/fixtures/flock-child.js new file mode 100644 index 0000000000..81033f4bd6 --- /dev/null +++ b/native/system/test/fixtures/flock-child.js @@ -0,0 +1,42 @@ +/** IPC-controlled lock holder; acknowledgements follow settled syscalls or close. */ +import assert from 'node:assert/strict'; +import { once, on } from 'node:events'; +import { closeSync, openSync } from 'node:fs'; +import { tryLockExclusive } from '../../packages/entry/lib/flock.js'; + +const messages = on(process, 'message'); +let fd = openSync(process.argv[2], 'a+', 0o600); +try { + await send({ type: 'ready' }); + for await (const [command] of messages) { + if (command === 'close') { + closeSync(fd); + fd = undefined; + await send({ type: 'closed' }); + break; + } + assert.equal(command, 'tryLock'); + let reply; + try { + await tryLockExclusive(fd); + reply = { type: 'locked' }; + } catch (error) { + reply = { type: 'error', code: error.code, errno: error.errno, syscall: error.syscall }; + } + await send(reply); + } +} finally { + await messages.return(); + if (fd !== undefined) closeSync(fd); + if (process.connected) { + const disconnected = once(process, 'disconnect'); + process.disconnect(); + await disconnected; + } +} + +function send(message) { + return new Promise((resolve, reject) => { + process.send(message, (error) => error ? reject(error) : resolve()); + }); +} diff --git a/native/system/test/fixtures/flock-import.js b/native/system/test/fixtures/flock-import.js new file mode 100644 index 0000000000..49ca011fe8 --- /dev/null +++ b/native/system/test/fixtures/flock-import.js @@ -0,0 +1,22 @@ +/** A separate process keeps unsupported-platform simulation away from other tests. */ +import assert from 'node:assert/strict'; + +const platform = process.argv[2]; +const descriptor = Object.getOwnPropertyDescriptor(process, 'platform'); +try { + if (platform) Object.defineProperty(process, 'platform', { value: platform }); + const { tryLockExclusive } = await import('../../packages/entry/lib/flock.js'); + assert.equal(typeof tryLockExclusive, 'function'); + if (platform || (process.platform !== 'linux' && process.platform !== 'darwin')) { + await assert.rejects(tryLockExclusive(-1), { + code: 'ERR_FLOCK_UNSUPPORTED_PLATFORM', + syscall: 'flock', + }); + } +} finally { + Object.defineProperty(process, 'platform', descriptor); +} +await new Promise((resolve, reject) => { + process.send({ type: 'ready' }, (error) => error ? reject(error) : resolve()); +}); +process.disconnect(); diff --git a/native/system/test/fixtures/flock-inherited-child.js b/native/system/test/fixtures/flock-inherited-child.js new file mode 100644 index 0000000000..2d434af541 --- /dev/null +++ b/native/system/test/fixtures/flock-inherited-child.js @@ -0,0 +1,37 @@ +/** fd 4 is inherited through spawn's stdio mapping, never reopened by path. */ +import assert from 'node:assert/strict'; +import { on } from 'node:events'; +import { closeSync, fstatSync } from 'node:fs'; +import { tryLockExclusive } from '../../packages/entry/lib/flock.js'; + +const messages = on(process, 'message'); +let fd = 4; +try { + assert.ok(fstatSync(fd).isFile()); + await send({ type: 'ready' }); + for await (const [command] of messages) { + if (command === 'quit') { + await send({ type: 'bye' }); + break; + } + if (command === 'close') { + closeSync(fd); + fd = undefined; + await send({ type: 'closed' }); + continue; + } + assert.equal(command, 'tryLock'); + await tryLockExclusive(fd); + await send({ type: 'locked' }); + } +} finally { + await messages.return(); + if (fd !== undefined) closeSync(fd); + if (process.connected) process.disconnect(); +} + +function send(message) { + return new Promise((resolve, reject) => { + process.send(message, (error) => error ? reject(error) : resolve()); + }); +} diff --git a/native/system/test/fixtures/flock-io-child.js b/native/system/test/fixtures/flock-io-child.js new file mode 100644 index 0000000000..3c96377425 --- /dev/null +++ b/native/system/test/fixtures/flock-io-child.js @@ -0,0 +1,19 @@ +/** Ordinary file I/O from a process that never acquires a lock. */ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { readFileSync, writeFileSync } from 'node:fs'; + +const request = once(process, 'message'); +await send({ type: 'ready' }); +const [command] = await request; +assert.equal(command, 'read-write'); +const previous = readFileSync(process.argv[2], 'utf8'); +writeFileSync(process.argv[2], 'written without acquiring a lock'); +await send({ type: 'written', previous }); +process.disconnect(); + +function send(message) { + return new Promise((resolve, reject) => { + process.send(message, (error) => error ? reject(error) : resolve()); + }); +} diff --git a/native/system/test/fixtures/flock-oracle.c b/native/system/test/fixtures/flock-oracle.c new file mode 100644 index 0000000000..ef1e9f6857 --- /dev/null +++ b/native/system/test/fixtures/flock-oracle.c @@ -0,0 +1,75 @@ +/* Independent system flock(2) oracle; stdin commands produce flushed JSON lines. */ +#include +#include +#include +#include +#include +#include + +static int reply(int fd, int operation) { + const int result = flock(fd, operation); + const int error = result == 0 ? 0 : errno; + if (printf("{\"errno\":%d}\n", error) < 0 || fflush(stdout) == EOF) { + perror("flock-oracle: stdout"); + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + int operation = LOCK_EX; + if (argc < 2 || argc > 3) { + fprintf(stderr, "usage: flock-oracle [exclusive|shared]\n"); + return 1; + } + if (argc == 3) { + if (strcmp(argv[2], "shared") == 0) { + operation = LOCK_SH; + } else if (strcmp(argv[2], "exclusive") != 0) { + fprintf(stderr, "flock-oracle: mode must be exclusive or shared\n"); + return 1; + } + } + + const int fd = open(argv[1], O_RDWR | O_CREAT, 0600); + if (fd == -1) { + perror("flock-oracle: open"); + return 1; + } + int status = 0; + if (puts("{\"ready\":true}") == EOF || fflush(stdout) == EOF) { + perror("flock-oracle: stdout"); + status = 1; + goto cleanup; + } + + char command[3]; + while (fgets(command, sizeof(command), stdin) != NULL) { + if (command[1] != '\n') { + fprintf(stderr, "flock-oracle: commands must be one letter followed by a newline\n"); + status = 1; + break; + } + if (command[0] == 'q') break; + if (command[0] != 't' && command[0] != 'u') { + fprintf(stderr, "flock-oracle: expected t, u, or q\n"); + status = 1; + break; + } + if (reply(fd, command[0] == 't' ? operation | LOCK_NB : LOCK_UN) != 0) { + status = 1; + break; + } + } + if (ferror(stdin)) { + perror("flock-oracle: stdin"); + status = 1; + } + +cleanup: + if (close(fd) == -1) { + perror("flock-oracle: close"); + status = 1; + } + return status; +} diff --git a/native/system/test/fixtures/flock-worker.js b/native/system/test/fixtures/flock-worker.js new file mode 100644 index 0000000000..28cc056023 --- /dev/null +++ b/native/system/test/fixtures/flock-worker.js @@ -0,0 +1,31 @@ +/** Hold work before or inside its callback while the parent terminates this environment. */ +import assert from 'node:assert/strict'; +import { createHook } from 'node:async_hooks'; +import { parentPort, workerData } from 'node:worker_threads'; +import { tryLockExclusive } from '../../packages/entry/lib/flock.js'; +import { loadFlockBinding } from './flock-binding.js'; + +let nativeWork = 0; +const hook = createHook({ + init(_id, type) { + if (type === 'flock') nativeWork++; + }, +}); +hook.enable(); +try { + if (workerData.phase === 'callback') { + loadFlockBinding().tryLock(workerData.fd, () => { + parentPort.postMessage({ type: 'callback', nativeWork }); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); + }); + } else { + const pending = tryLockExclusive(workerData.fd); + assert.equal(nativeWork, 1); + parentPort.postMessage({ type: 'queued', nativeWork }); + // No JS yield precedes this wait, so the native completion cannot run first. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); + await pending; + } +} finally { + hook.disable(); +} diff --git a/native/system/test/flock.test.js b/native/system/test/flock.test.js new file mode 100644 index 0000000000..16e66327e9 --- /dev/null +++ b/native/system/test/flock.test.js @@ -0,0 +1,423 @@ +/** Kernel behavior through the built flock entry; each case owns its files and processes. */ +import assert from 'node:assert/strict'; +import { fork, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { constants, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; +import { tryLockExclusive } from '../packages/entry/lib/flock.js'; +import { loadFlockBinding } from './fixtures/flock-binding.js'; + +const posix = process.platform === 'linux' || process.platform === 'darwin'; +const timeout = 120_000; +const nativeOnly = { timeout, skip: posix ? false : 'The flock addon requires Linux or macOS' }; + +function resources(t) { + const disposers = []; + // Match the repository's process-e2e budget for both cases and cleanup. + t.after(async () => { + // Cleanup must await close even when the case's signal is already aborted. + const signal = AbortSignal.timeout(timeout); + const errors = []; + for (const dispose of disposers.reverse()) { + try { + await dispose(signal); + } catch (error) { + errors.push(error); + } + } + if (errors.length) throw new AggregateError(errors, 'flock test cleanup failed'); + }, { timeout }); + // On local Linux filesystems, flock and OFD byte-range locks are independent; + // network filesystems can translate between them and hide a wrong syscall. + const root = mkdtempSync(join(tmpdir(), 'node-addon-system-flock-')); + disposers.push(() => rmSync(root, { recursive: true, force: true })); + return { + file: join(root, 'lock'), + defer: (dispose) => disposers.push(dispose), + open(name = 'lock') { + const fd = openSync(join(root, name), 'a+', 0o600); + let closed = false; + const close = () => { + if (!closed) { + closeSync(fd); + closed = true; + } + }; + disposers.push(close); + return { fd, close }; + }, + }; +} + +function flockError(error, codes) { + assert.ok(codes.includes(error.code), `Unexpected flock error: ${error.code}`); + assert.equal(error.errno, constants.errno[error.code]); + assert.ok(error.errno > 0); + assert.equal(error.syscall, 'flock'); + return true; +} + +const busy = (error) => flockError(error, ['EAGAIN', 'EWOULDBLOCK']); + +async function until(promise, signal) { + let abort; + const cancelled = new Promise((_, reject) => { + abort = () => reject(signal.reason); + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + }); + try { + return await Promise.race([promise, cancelled]); + } finally { + signal.removeEventListener('abort', abort); + } +} + +function childEnvironment() { + return Object.fromEntries(Object.entries(process.env) + .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD/i.test(name))); +} + +function observeChild(t, scope, child) { + let closed = false; + let stderr = ''; + let processError; + const done = new Promise((resolve) => child.once('close', (code, signal) => { + closed = true; + resolve({ code, signal, stderr, error: processError }); + })); + scope.defer(async (signal) => { + if (!closed) child.kill('SIGKILL'); + await until(done, signal); + }); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (error) => { processError = error; }); + + async function response(emitter, event, send = () => Promise.resolve()) { + const waiting = new AbortController(); + const signal = AbortSignal.any([t.signal, waiting.signal]); + try { + const received = Promise.race([ + once(emitter, event, { signal }).then(([message]) => message), + done.then((result) => { + throw new Error(`flock fixture exited before replying: ${JSON.stringify(result)}`); + }), + ]); + const [message] = await until(Promise.all([received, send()]), signal); + return message; + } finally { + waiting.abort(); + } + } + + return { child, response, waitForExit: () => until(done, t.signal) }; +} + +async function childFixture(t, scope, fixture, args = [], { execArgv = [], inheritedFd } = {}) { + const stdio = ['ignore', 'ignore', 'pipe', 'ipc']; + if (inheritedFd !== undefined) stdio.push(inheritedFd); + const child = fork(new URL(`./fixtures/${fixture}`, import.meta.url), args, { + execArgv, + env: childEnvironment(), + stdio, + }); + const observed = observeChild(t, scope, child); + const exchange = (command) => observed.response(child, 'message', () => ( + command === undefined ? Promise.resolve() : new Promise((resolve, reject) => { + child.send(command, (error) => error ? reject(error) : resolve()); + }) + )); + assert.deepEqual(await exchange(), { type: 'ready' }); + return { child, waitForExit: observed.waitForExit, exchange }; +} + +async function oracleFixture(t, scope, mode) { + const binary = process.platform === 'linux' + ? `./bin/${process.report.getReport().header.glibcVersionRuntime ? 'glibc' : 'musl'}/flock-oracle` + : './bin/flock-oracle'; + const child = spawn(fileURLToPath(new URL(binary, import.meta.url)), [scope.file, mode], { + env: childEnvironment(), + stdio: ['pipe', 'pipe', 'pipe'], + }); + const observed = observeChild(t, scope, child); + const lines = createInterface({ input: child.stdout }); + child.once('close', () => lines.close()); + let inputError; + child.stdin.on('error', (error) => { inputError = error; }); + const send = (command) => until(new Promise((resolve, reject) => { + if (inputError) reject(inputError); + else child.stdin.write(`${command}\n`, (error) => error ? reject(error) : resolve()); + }), t.signal); + const exchange = async (command) => JSON.parse(await observed.response(lines, 'line', () => ( + command === undefined ? Promise.resolve() : send(command) + ))); + assert.deepEqual(await exchange(), { ready: true }); + return { + exchange, + async quit() { + await send('q'); + cleanExit(await observed.waitForExit()); + }, + }; +} + +function cleanExit(result) { + assert.equal(result.signal, null, result.stderr); + assert.equal(result.error, undefined); + assert.equal(result.code, 0, result.stderr); +} + +test('import succeeds with native addons disabled', { timeout }, async (t) => { + const scope = resources(t); + const child = await childFixture(t, scope, 'flock-import.js', [], { execArgv: ['--no-addons'] }); + cleanExit(await child.waitForExit()); +}); + +for (const platform of ['win32', 'freebsd']) { + test(`calling flock on ${platform} rejects without loading an addon`, { timeout }, async (t) => { + const scope = resources(t); + const child = await childFixture(t, scope, 'flock-import.js', [platform], { execArgv: ['--no-addons'] }); + cleanExit(await child.waitForExit()); + }); +} + +test('acquisition resolves asynchronously to void and the same fd can reacquire', nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + const result = tryLockExclusive(owner.fd); + assert.ok(result instanceof Promise); + assert.equal(await result, undefined); + assert.equal(await tryLockExclusive(owner.fd), undefined); +}); + +test('separate opens of one file contend', nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + const contender = scope.open(); + await tryLockExclusive(owner.fd); + await assert.rejects(tryLockExclusive(contender.fd), busy); +}); + +test('different files can be locked concurrently', nativeOnly, async (t) => { + const scope = resources(t); + const first = scope.open('first'); + const second = scope.open('second'); + await Promise.all([tryLockExclusive(first.fd), tryLockExclusive(second.fd)]); +}); + +test('closing the locked fd allows an already-open contender to acquire', nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + const contender = scope.open(); + await tryLockExclusive(owner.fd); + await assert.rejects(tryLockExclusive(contender.fd), busy); + owner.close(); + await tryLockExclusive(contender.fd); +}); + +test('closing another fd for the same file does not release the lock', nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + const other = scope.open(); + const contender = scope.open(); + await tryLockExclusive(owner.fd); + other.close(); + await assert.rejects(tryLockExclusive(contender.fd), busy); + owner.close(); + await tryLockExclusive(contender.fd); +}); + +test('invalid fd rejects asynchronously with EBADF and positive errno', nativeOnly, async () => { + let result; + assert.doesNotThrow(() => { result = tryLockExclusive(-1); }); + assert.ok(result instanceof Promise); + await assert.rejects(result, (error) => flockError(error, ['EBADF'])); +}); + +test('native argument errors reject the JavaScript promise without throwing from the entry', nativeOnly, async () => { + let result; + assert.doesNotThrow(() => { result = tryLockExclusive(2 ** 31); }); + assert.ok(result instanceof Promise); + await assert.rejects(result, { name: 'RangeError', message: 'fd must be a signed C int' }); +}); + +test('native callbacks receive asynchronous, request-local success and errno results', nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + const contender = scope.open(); + await tryLockExclusive(owner.fd); + const binding = loadFlockBinding(); + const results = await Promise.all([owner.fd, contender.fd, -1].map((fd) => new Promise((resolve) => { + let returned = false; + const result = binding.tryLock(fd, (errno) => { + assert.equal(returned, true); + resolve(errno); + }); + assert.equal(result, undefined); + returned = true; + }))); + assert.equal(results[0], 0); + assert.ok([constants.errno.EAGAIN, constants.errno.EWOULDBLOCK].includes(results[1])); + assert.equal(results[2], constants.errno.EBADF); +}); + +test('an exception in the native completion callback is reported as uncaught', nativeOnly, async (t) => { + const scope = resources(t); + const child = await childFixture(t, scope, 'flock-callback-throws.js'); + const exit = await child.waitForExit(); + assert.equal(exit.signal, null, exit.stderr); + assert.equal(exit.error, undefined); + assert.equal(exit.code, 1, exit.stderr); + assert.match(exit.stderr, /Error: flock callback failure/); +}); + +test('concurrent calls retain their own syscall errno', nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + const contender = scope.open(); + await tryLockExclusive(owner.fd); + await Promise.all([ + assert.rejects(tryLockExclusive(contender.fd), busy), + assert.rejects(tryLockExclusive(-1), (error) => flockError(error, ['EBADF'])), + assert.rejects(tryLockExclusive(contender.fd), busy), + assert.rejects(tryLockExclusive(-1), (error) => flockError(error, ['EBADF'])), + ]); +}); + +test('two child processes exclude each other and normal close transfers ownership', nativeOnly, async (t) => { + const scope = resources(t); + const observer = scope.open(); + const children = await Promise.all([ + childFixture(t, scope, 'flock-child.js', [scope.file]), + childFixture(t, scope, 'flock-child.js', [scope.file]), + ]); + const results = await Promise.all(children.map((child) => child.exchange('tryLock'))); + assert.equal(results.filter((result) => result.type === 'locked').length, 1); + assert.equal(results.filter((result) => result.type === 'error').length, 1); + const winnerIndex = results.findIndex((result) => result.type === 'locked'); + const winner = children[winnerIndex]; + const loser = children[1 - winnerIndex]; + busy(results[1 - winnerIndex]); + await assert.rejects(tryLockExclusive(observer.fd), busy); + + assert.deepEqual(await winner.exchange('close'), { type: 'closed' }); + cleanExit(await winner.waitForExit()); + assert.deepEqual(await loser.exchange('tryLock'), { type: 'locked' }); + await assert.rejects(tryLockExclusive(observer.fd), busy); + assert.deepEqual(await loser.exchange('close'), { type: 'closed' }); + cleanExit(await loser.waitForExit()); + await tryLockExclusive(observer.fd); +}); + +test('SIGKILL releases a child lock after exit', nativeOnly, async (t) => { + const scope = resources(t); + const observer = scope.open(); + const owner = await childFixture(t, scope, 'flock-child.js', [scope.file]); + const contender = await childFixture(t, scope, 'flock-child.js', [scope.file]); + assert.deepEqual(await owner.exchange('tryLock'), { type: 'locked' }); + const rejected = await contender.exchange('tryLock'); + assert.equal(rejected.type, 'error'); + busy(rejected); + assert.equal(owner.child.kill('SIGKILL'), true); + const exit = await owner.waitForExit(); + assert.equal(exit.error, undefined); + assert.equal(exit.signal, 'SIGKILL'); + assert.equal(exit.code, null); + assert.deepEqual(await contender.exchange('tryLock'), { type: 'locked' }); + await assert.rejects(tryLockExclusive(observer.fd), busy); +}); + +for (const phase of ['queued', 'callback']) { + test(`worker termination during ${phase} drains native work without taking ownership of the fd`, nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + const contender = scope.open(); + const worker = new Worker(new URL('./fixtures/flock-worker.js', import.meta.url), { + workerData: { fd: owner.fd, phase }, + execArgv: [], + }); + scope.defer((signal) => until(worker.terminate(), signal)); + const exited = once(worker, 'exit'); + const waiting = new AbortController(); + try { + const [message] = await Promise.race([ + once(worker, 'message', { signal: AbortSignal.any([t.signal, waiting.signal]) }), + exited.then(([code]) => { throw new Error(`flock worker exited before ${phase}: ${code}`); }), + ]); + assert.deepEqual(message, { type: phase, nativeWork: 1 }); + } finally { + waiting.abort(); + } + assert.equal(await until(worker.terminate(), t.signal), 1); + await until(exited, t.signal); + await tryLockExclusive(owner.fd); + await assert.rejects(tryLockExclusive(contender.fd), busy); + owner.close(); + await tryLockExclusive(contender.fd); + }); +} + +for (const mode of ['exclusive', 'shared']) { + test(`an addon exclusive lock blocks an independent C ${mode} flock until its fd closes`, nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + await tryLockExclusive(owner.fd); + const oracle = await oracleFixture(t, scope, mode); + const result = await oracle.exchange('t'); + assert.ok([constants.errno.EAGAIN, constants.errno.EWOULDBLOCK].includes(result.errno)); + owner.close(); + assert.deepEqual(await oracle.exchange('t'), { errno: 0 }); + await oracle.quit(); + }); + + test(`an independent C ${mode} flock blocks the addon until explicit unlock`, nativeOnly, async (t) => { + const scope = resources(t); + const contender = scope.open(); + const oracle = await oracleFixture(t, scope, mode); + assert.deepEqual(await oracle.exchange('t'), { errno: 0 }); + await assert.rejects(tryLockExclusive(contender.fd), busy); + assert.deepEqual(await oracle.exchange('u'), { errno: 0 }); + await tryLockExclusive(contender.fd); + await oracle.quit(); + }); +} + +test('an advisory exclusive lock permits another process to read and write without locking', nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + const contender = scope.open(); + writeFileSync(scope.file, 'written before locking'); + await tryLockExclusive(owner.fd); + await assert.rejects(tryLockExclusive(contender.fd), busy); + const child = await childFixture(t, scope, 'flock-io-child.js', [scope.file]); + assert.deepEqual(await child.exchange('read-write'), { + type: 'written', previous: 'written before locking', + }); + cleanExit(await child.waitForExit()); + assert.equal(readFileSync(scope.file, 'utf8'), 'written without acquiring a lock'); + await assert.rejects(tryLockExclusive(contender.fd), busy); +}); + +test('an inherited fd shares the lock after parent close until the child closes its last reference', nativeOnly, async (t) => { + const scope = resources(t); + const owner = scope.open(); + const contender = scope.open(); + await tryLockExclusive(owner.fd); + const child = await childFixture(t, scope, 'flock-inherited-child.js', [], { inheritedFd: owner.fd }); + assert.deepEqual(await child.exchange('tryLock'), { type: 'locked' }); + owner.close(); + await assert.rejects(tryLockExclusive(contender.fd), busy); + assert.deepEqual(await child.exchange('close'), { type: 'closed' }); + await tryLockExclusive(contender.fd); + // The child stays alive, so its close acknowledgement—not process exit—releases the lock. + assert.equal(child.child.exitCode, null); + assert.equal(child.child.signalCode, null); + assert.deepEqual(await child.exchange('quit'), { type: 'bye' }); + cleanExit(await child.waitForExit()); +}); diff --git a/native/landlock-run/test/launcher.test.js b/native/system/test/launcher.test.js similarity index 99% rename from native/landlock-run/test/launcher.test.js rename to native/system/test/launcher.test.js index a78501cd4a..01fbc8458e 100644 --- a/native/landlock-run/test/launcher.test.js +++ b/native/system/test/launcher.test.js @@ -22,7 +22,7 @@ import { grantArgs, launcherPath, probe, -} from '@deepseek-ai/node-addon-landlock-run'; +} from '@deepseek-ai/node-addon-system/landlock-run'; const FATAL_PREFIX = 'landlock-run: '; const PARTIAL_NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'; diff --git a/native/system/test/link-platform.mjs b/native/system/test/link-platform.mjs new file mode 100644 index 0000000000..15c5aeacb4 --- /dev/null +++ b/native/system/test/link-platform.mjs @@ -0,0 +1,10 @@ +/** Link the downloaded platform artifact for dependency-free ABI tests. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packages = fileURLToPath(new URL('../packages/', import.meta.url)); +const platform = `${process.platform}-${process.arch}`; +const parent = path.join(packages, 'entry/node_modules/@deepseek-ai'); +fs.mkdirSync(parent, { recursive: true }); +fs.symlinkSync(path.join(packages, platform), path.join(parent, `node-addon-system-${platform}`), 'junction'); diff --git a/native/system/test/package-matrix.test.js b/native/system/test/package-matrix.test.js new file mode 100644 index 0000000000..12ea4c3030 --- /dev/null +++ b/native/system/test/package-matrix.test.js @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { verifyPlatformBinaries } from '../scripts/repo.mjs'; + +test('the real Landlock subpath imports without platform packages or dlopen, while the root is unexported', { timeout: 120_000 }, (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'system-landlock-entry-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true }), { timeout: 120_000 }); + const entry = fileURLToPath(new URL('../packages/entry/', import.meta.url)); + const installed = path.join(dir, 'node_modules', '@deepseek-ai', 'node-addon-system'); + fs.mkdirSync(installed, { recursive: true }); + fs.copyFileSync(path.join(entry, 'package.json'), path.join(installed, 'package.json')); + // Only the real entry payload is present; no platform package or addon is copied. + fs.cpSync(path.join(entry, 'lib'), path.join(installed, 'lib'), { recursive: true }); + const manifest = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf8')); + assert.equal(manifest.main, undefined); + assert.equal(manifest.types, undefined); + + const result = spawnSync(process.execPath, ['--no-addons', '--input-type=module', '--eval', ` + import assert from 'node:assert/strict'; + import { createRequire } from 'node:module'; + const originalDlopen = process.dlopen; + let dlopenCalls = 0; + try { + process.dlopen = () => { + dlopenCalls++; + throw new Error('Landlock import attempted dlopen'); + }; + const api = await import('@deepseek-ai/node-addon-system/landlock-run'); + assert.equal(api.LAUNCHER_BIN, 'landlock-run'); + assert.deepEqual(api.grantArgs({}), []); + assert.equal(dlopenCalls, 0); + await assert.rejects(import('@deepseek-ai/node-addon-system'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); + assert.throws(() => createRequire(import.meta.url).resolve('@deepseek-ai/node-addon-system'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); + } finally { + process.dlopen = originalDlopen; + } + `], { + cwd: dir, + encoding: 'utf8', + timeout: 120_000, + env: Object.fromEntries(Object.entries(process.env) + .filter(([key]) => !/KEY|TOKEN|SECRET|PASSWORD|^NODE_PATH$/i.test(key))), + }); + assert.equal(result.error, undefined); + assert.equal(result.signal, null, result.stderr); + assert.equal(result.status, 0, result.stderr); +}); + +// These minimal headers exercise format rejection, not executable behavior. +// flock.test.js and packed-install verification execute the real addon. +function fixture(t, { platform = 'linux', arch = 'x64', kind = 'node-api' } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'system-package-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const executable = kind === 'static-musl'; + const binary = executable + ? { tool: 'landlock-run', kind, path: 'bin/landlock-run' } + : { tool: 'flock', kind, napi: 8, ...(platform === 'linux' ? { libc: 'glibc' } : {}), path: 'bin/system.node' }; + const spec = { platform: `${platform}-${arch}`, binaries: [binary] }; + const manifest = { name: 'fixture', os: [platform], cpu: [arch] }; + const bytes = Buffer.alloc(256); + if (platform === 'linux') { + bytes.writeUInt32LE(0x464c457f, 0); + bytes[4] = 2; + bytes[5] = 1; + bytes.writeUInt16LE(executable ? 2 : 3, 16); + bytes.writeUInt16LE(arch === 'x64' ? 62 : 183, 18); + } else { + bytes.writeUInt32LE(0xfeedfacf, 0); + bytes.writeUInt32LE(arch === 'x64' ? 0x01000007 : 0x0100000c, 4); + bytes.writeUInt32LE(8, 12); + } + bytes.write('napi_register_module_v1\0node_api_module_get_api_version_v1', 64); + const file = path.join(dir, binary.path); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, bytes, { mode: executable ? 0o755 : 0o644 }); + const save = () => { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(manifest)); + fs.writeFileSync(path.join(dir, 'prebuilds.json'), JSON.stringify(spec)); + }; + save(); + return { dir, file, bytes, binary, spec, manifest, save }; +} + +for (const platform of ['linux', 'darwin']) { + for (const arch of ['x64', 'arm64']) { + test(`accepts ${platform}-${arch} addon metadata and header`, (t) => { + assert.equal(verifyPlatformBinaries(fixture(t, { platform, arch }).dir).count, 1); + }); + } +} + +test('accepts the Linux static launcher format', (t) => { + assert.equal(verifyPlatformBinaries(fixture(t, { kind: 'static-musl' }).dir).count, 1); +}); + +for (const [name, change, expected] of [ + ['unknown platform', (f) => { f.manifest.os = ['win32']; }, /os\/cpu/], + ['mismatched platform', (f) => { f.spec.platform = 'linux-arm64'; }, /disagrees/], + ['path outside bin', (f) => { f.binary.path = '../system.node'; }, /inside bin/], + ['duplicate binary', (f) => { f.spec.binaries.push({ ...f.binary }); }, /duplicate/], + ['unknown kind', (f) => { f.binary.kind = 'unknown'; }, /kind\/tool\/NAPI/], + ['wrong NAPI version', (f) => { f.binary.napi = 9; }, /kind\/tool\/NAPI/], + ['missing Linux libc', (f) => { delete f.binary.libc; }, /declare glibc or musl/], + ['missing payload', (f) => { fs.unlinkSync(f.file); }, /missing/], + ['wrong ELF architecture', (f) => { f.bytes.writeUInt16LE(183, 18); fs.writeFileSync(f.file, f.bytes); }, /ELF architecture/], + ['wrong ELF type', (f) => { f.bytes.writeUInt16LE(2, 16); fs.writeFileSync(f.file, f.bytes); }, /ELF file type/], + ['truncated ELF', (f) => { fs.writeFileSync(f.file, Buffer.alloc(8)); }, /ELF64/], + ['missing NAPI exports', (f) => { f.bytes.fill(0, 64); fs.writeFileSync(f.file, f.bytes); }, /Node-API entry points/], + ['undeclared nested file', (f) => { fs.mkdirSync(path.join(f.dir, 'bin/extra')); fs.writeFileSync(path.join(f.dir, 'bin/extra/other.node'), 'x'); }, /undeclared/], +]) { + test(`rejects ${name}`, (t) => { + const f = fixture(t); + change(f); + f.save(); + assert.throws(() => verifyPlatformBinaries(f.dir), expected); + }); +} + +test('rejects Linux libc metadata on macOS', (t) => { + const f = fixture(t, { platform: 'darwin' }); + f.binary.libc = 'musl'; + f.save(); + assert.throws(() => verifyPlatformBinaries(f.dir), /must not declare/); +}); + +for (const [offset, value] of [[0, 0], [4, 0], [12, 2]]) { + test(`rejects invalid Mach-O field at ${offset}`, (t) => { + const f = fixture(t, { platform: 'darwin' }); + f.bytes.writeUInt32LE(value, offset); + fs.writeFileSync(f.file, f.bytes); + assert.throws(() => verifyPlatformBinaries(f.dir), /Mach-O/); + }); +} + +test('rejects a launcher whose executable bit was lost', { skip: process.platform === 'win32' }, (t) => { + const f = fixture(t, { kind: 'static-musl' }); + fs.chmodSync(f.file, 0o644); + assert.throws(() => verifyPlatformBinaries(f.dir), /not executable/); +}); + +test('rejects a symbolic-link payload', { skip: process.platform === 'win32' }, (t) => { + const f = fixture(t); + fs.renameSync(f.file, f.file + '.target'); + fs.symlinkSync(f.file + '.target', f.file); + assert.throws(() => verifyPlatformBinaries(f.dir), /not a regular file/); +}); + +test('entry prepack rejects a missing exported flock file even when the Landlock entry exists', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'system-entry-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + fs.mkdirSync(path.join(dir, 'lib')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'entry-fixture', + exports: { + './landlock-run': { types: './lib/index.d.ts', default: './lib/index.js' }, + './flock': { types: './lib/flock.d.ts', default: './lib/flock.js' }, + }, + })); + for (const file of ['index.js', 'index.d.ts', 'flock.d.ts']) fs.writeFileSync(path.join(dir, 'lib', file), ''); + const script = fileURLToPath(new URL('../scripts/verify-entry-lib.mjs', import.meta.url)); + const options = { + cwd: dir, + encoding: 'utf8', + timeout: 120_000, + env: Object.fromEntries(Object.entries(process.env).filter(([key]) => !/KEY|TOKEN|SECRET|PASSWORD/i.test(key))), + }; + const missing = spawnSync(process.execPath, [script], options); + assert.equal(missing.error, undefined); + assert.equal(missing.signal, null); + assert.equal(missing.status, 1); + assert.match(missing.stderr, /lib\/flock\.js/); + fs.writeFileSync(path.join(dir, 'lib/flock.js'), ''); + const complete = spawnSync(process.execPath, [script], options); + assert.equal(complete.error, undefined); + assert.equal(complete.signal, null); + assert.equal(complete.status, 0, complete.stderr); +}); diff --git a/native/landlock-run/tsconfig.base.json b/native/system/tsconfig.base.json similarity index 100% rename from native/landlock-run/tsconfig.base.json rename to native/system/tsconfig.base.json diff --git a/native/landlock-run/tsconfig.json b/native/system/tsconfig.json similarity index 100% rename from native/landlock-run/tsconfig.json rename to native/system/tsconfig.json diff --git a/package.json b/package.json index 1c4c8f6f86..91ca336e0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "license": "MIT", "private": true, "type": "module", @@ -11,14 +11,14 @@ "workspaces": [ "vendor/*", "packages/*/*", - "native/landlock-run", - "native/landlock-run/packages/*", + "native/system", + "native/system/packages/*", "apps/*", "website" ], "scripts": { "build": "tsx scripts/build.ts", - "build:bench": "npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", + "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", @@ -48,14 +48,16 @@ "lint:fix": "npm run build:lib:host && npm run lint:fix:contracts-ready", "lint:fix:contracts-ready": "tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", - "test": "vitest run", - "test:coverage": "vitest run --coverage", + "test": "pnpm run build:native-system && vitest run", + "test:coverage": "pnpm run build:native-system && vitest run --coverage", + "build:native-system": "tsx native/system/scripts/build.ts --host-addon-only", "test:coverage:partitioned": "tsx scripts/run-coverage-partitions.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:bench": "npm run build:bench && npm run build:web && npm run test:bench:built", "test:bench:built": "vitest run --config vitest.bench.config.ts", "test:expected": "vitest run --config vitest.expected.config.ts", "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts", + "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs", "test:issue-management": "node .github/issue-management/policy.test.mjs", "test:request-review": "node --test .github/review-ownership/request-review.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 8b7c70841b..c7a821c5d5 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/acp/acp/src/session.ts b/packages/acp/acp/src/session.ts index a4e93f1e1a..3fff075359 100644 --- a/packages/acp/acp/src/session.ts +++ b/packages/acp/acp/src/session.ts @@ -150,10 +150,7 @@ export class AcpSession { resumeSessionId: options.sessionId, agentOptions: options.agentOptions, signal: options.signal, - setup: async (agentCtx) => { - const agent = agentCtx.agent - /* v8 ignore next -- Agent factory setup always carries its unpublished Agent. */ - if (agent === undefined) throw new Error('acp: resumed Agent is absent during setup') + setup: async (agentCtx, agent) => { modelControl = new AcpModelControl( ctx.llm, selectionFor(agent.session.requestHeader(), options.fallbackSelection), diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index b99f7d48da..ad1fa30fac 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -942,7 +942,9 @@ describe('automation-only ACP bridge', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - expect(harness.adapter.requests[0]?.system).toContain(`Automation persona for mock in ${process.cwd()}.`) + const head = harness.adapter.requests[0]?.messages[0] + expect(head?.role).toBe('system') + expect(head?.content).toContainEqual({ type: 'text', text: expect.stringContaining(`Automation persona for mock in ${process.cwd()}.`) as unknown }) }) it('requires one absolute primary workspace', async () => { diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 4aa32f078c..325255fb81 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -7,10 +7,14 @@ import { makeBridgeHarness, type BridgeHarness } from './harness.ts' describe('ACP connection ownership', () => { let harness: BridgeHarness | undefined + let releaseBlockedDisposal: (() => void) | undefined afterEach(async () => { - await harness?.dispose() + const bridge = harness harness = undefined + releaseBlockedDisposal?.() + releaseBlockedDisposal = undefined + await bridge?.dispose() }) it('disposal cancels a running prompt and awaits agent teardown', async () => { @@ -198,34 +202,58 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.list()).toHaveLength(0) }) - it('a client disconnect disposes every owned session without root-context disposal', async () => { + it.each([ + ['a client disconnect', 'closeClientTransport'], + ['a failed client transport', 'abortClientTransport'], + ] as const)('%s disposes its session without plugin disposal', async (_name, disconnect) => { harness = await makeBridgeHarness({ script: ['hang'] }) - await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(SessionId(sessionId))! - void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) - await vi.waitFor(() => { expect(agent.status).toBe('running') }) - - await harness.closeClientTransport() - await harness.acpFiber.dispose() - expect(agent.status).toBe('idle') - expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() - expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() - }) - - it('a failed client transport still disposes every owned session', async () => { - harness = await makeBridgeHarness({ script: ['hang'] }) - await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(SessionId(sessionId))! - void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) - await vi.waitFor(() => { expect(agent.status).toBe('running') }) - - await harness.abortClientTransport() - await vi.waitFor(() => { - expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true) + const bridge = harness + const create = bridge.ctx.agents.create.bind(bridge.ctx.agents) + const disposalStarted = Promise.withResolvers() + const releaseDisposal = Promise.withResolvers() + releaseBlockedDisposal = () => { releaseDisposal.resolve(undefined) } + const disposalCompleted = Promise.withResolvers() + const createSpy = vi.spyOn(bridge.ctx.agents, 'create').mockImplementation(async (options) => { + const handle = await create(options) + const dispose = handle.dispose.bind(handle) + handle.dispose = () => { + const completion = (async () => { + disposalStarted.resolve(undefined) + await releaseDisposal.promise + await dispose() + return undefined + })() + disposalCompleted.resolve(completion) + return completion + } + return handle }) - expect(agent.status).toBe('idle') + const running = Promise.withResolvers() + let stopListening: (() => void) | undefined + try { + await bridge.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await bridge.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = bridge.ctx.agents.get(SessionId(sessionId))! + stopListening = bridge.ctx.on('agent/status', ({ agent: changed, status }) => { + if (changed === agent && status === 'running') running.resolve(undefined) + }) + void bridge.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) + await running.promise + + await bridge[disconnect]() + await disposalStarted.promise + expect(bridge.ctx.agents.get(SessionId(sessionId))).toBe(agent) + expect(bridge.ctx.sessions.get(SessionId(sessionId))).toBe(agent.session) + releaseDisposal.resolve(undefined) + await disposalCompleted.promise + expect(agent.status).toBe('idle') + expect(bridge.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + expect(bridge.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() + } finally { + releaseDisposal.resolve(undefined) + stopListening?.() + createSpy.mockRestore() + } }) it('disconnect and plugin disposal share one quiescence boundary', async () => { diff --git a/packages/acp/acp/tests/updates.spec.ts b/packages/acp/acp/tests/updates.spec.ts index 4363db4f86..4260afc711 100644 --- a/packages/acp/acp/tests/updates.spec.ts +++ b/packages/acp/acp/tests/updates.spec.ts @@ -11,6 +11,7 @@ function assistantEvent( ): SessionEvent<'assistant/message'> { return { type: 'assistant/message', + surfaceOp: 'append', seq: SessionSeq(0), time: 0, data: { @@ -64,6 +65,7 @@ describe('standard ACP update projection', () => { }) const result = await toolResultUpdate({ get: () => undefined } as unknown as Context, { type: 'tool/result', + surfaceOp: 'append', seq: SessionSeq(0), time: 0, data: { diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d7d9d4a0a8..8530dfd990 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 4ec7c3f9fd..ae813fde1a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -457,12 +457,7 @@ export class TypertGatewayService extends Service implements TypertGateway { private startRemoteEvent(source: TypertRemoteEventInvocation): void { try { assertRemoteEventName(source) - const context = this.ctx.typert.contexts.identifyHost(source.context.value) - if (context === undefined) { - source.resolve({ kind: 'next' }) - return - } - if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) { + if (!isRemoteEventAgentId(source.context.agentId)) { throw new TypeError( 'typert gateway: scoped Remote events require a non-empty Agent identity', ) @@ -476,7 +471,7 @@ export class TypertGatewayService extends Service implements TypertGateway { () => () => { this.cancelRemoteEvent( pending, - new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`), + new Error('typert gateway: Remote event Agent Context was released'), ) }, `api-gateway: Remote event ${JSON.stringify(source.event)}`, @@ -500,7 +495,7 @@ export class TypertGatewayService extends Service implements TypertGateway { type: 'waterfall', event: source.event, eventId: id, - agentId: context.identity, + agentId: source.context.agentId, request: projected.request, }, deliveries: new Set(), diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index b456efb4d0..f325d46e1a 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -28,10 +28,12 @@ export interface TypertRemoteEventFrame { /** Live Host values used to project one scoped Remote Event. */ export interface TypertRemoteEventContext { - /** Live Host Context identified by the registered Host adapters. */ + /** Live Agent Context that owns cancellation of the forwarded waterfall. */ readonly value: Context /** Agent object carried directly by the waterfall request. */ readonly subject: object + /** Agent identity read directly from the scoped event subject. */ + readonly agentId: string } /** Result returned from a Client waterfall, or delegation back to the Host chain. */ diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 2ad8c0225a..6244e1ec39 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -188,6 +188,7 @@ function pendingInvocation( context: Context, signal?: AbortSignal, prompt = 'ship', + identity: unknown = agentId('agent-1'), ): PendingInvocationProbe { const subject = { ctx: context } const settled = Promise.withResolvers() @@ -201,7 +202,7 @@ function pendingInvocation( dispatch: { event: 'fixture/approval', request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) }, - context: { value: context, subject }, + context: { value: context, subject, agentId: identity as string }, resolve, reject, }, @@ -466,13 +467,7 @@ describe('Typert Remote streams', () => { it('cancels a pending waterfall when its source rejects during removal', async () => { const { ctx } = await setup(true) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-removal') : undefined, - resolve: id => id === 'agent-removal' ? agent : undefined, - }) - const pending = pendingInvocation(agent) + const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-removal')) const rejected = expect(pending.outcome).rejects.toThrow( 'forwarded Remote event source was removed', ) @@ -497,7 +492,7 @@ describe('Typert Remote streams', () => { client.socket.close() }) - it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => { + it('rejects malformed scoped invocations and delegates a released Context', async () => { const { ctx } = await setup(false) const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) @@ -514,28 +509,15 @@ describe('Typert Remote streams', () => { await rejected } - const unavailable = pendingInvocation(ctx) - source.push(unavailable.dispatch) - await expect(unavailable.outcome).resolves.toEqual({ kind: 'next' }) - expect(unavailable.reject).not.toHaveBeenCalled() - let selected = ctx.extend() - let identity: unknown = 1n - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === selected ? identity as AgentWireId : undefined, - resolve: () => selected, - }) - const nonJsonIdentity = pendingInvocation(selected) + const nonJsonIdentity = pendingInvocation(selected, undefined, 'ship', 1n) const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow( 'require a non-empty Agent identity', ) source.push(nonJsonIdentity.dispatch) await nonJsonRejected - identity = 'agent-invalid-request' - const invalidRequest = pendingInvocation(selected) + const invalidRequest = pendingInvocation(selected, undefined, 'ship', agentId('agent-invalid-request')) const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow( 'must carry its scoped Agent directly', ) @@ -548,18 +530,16 @@ describe('Typert Remote streams', () => { const staleFiber = ctx.plugin(() => {}) await staleFiber selected = staleFiber.ctx - identity = 'agent-stale' await staleFiber.dispose() - const stale = pendingInvocation(selected) + const stale = pendingInvocation(selected, undefined, 'ship', agentId('agent-stale')) source.push(stale.dispatch) await expect(stale.outcome).resolves.toEqual({ kind: 'next' }) expect(stale.reject).not.toHaveBeenCalled() selected = ctx.extend() - identity = 'agent-cancelled' const abort = new AbortController() abort.abort('fixture non-error cancellation') - const cancelled = pendingInvocation(selected, abort.signal) + const cancelled = pendingInvocation(selected, abort.signal, 'ship', agentId('agent-cancelled')) const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({ message: 'typert gateway: Remote event was cancelled', cause: 'fixture non-error cancellation', @@ -597,19 +577,13 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-collision') : undefined, - resolve: id => id === 'agent-collision' ? agent : undefined, - }) const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) const firstAbort = new AbortController() const secondAbort = new AbortController() - const first = pendingInvocation(agent, firstAbort.signal, 'first') - const second = pendingInvocation(agent, secondAbort.signal, 'second') + const first = pendingInvocation(agent, firstAbort.signal, 'first', agentId('agent-collision')) + const second = pendingInvocation(agent, secondAbort.signal, 'second', agentId('agent-collision')) source.push(first.dispatch) await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) @@ -651,12 +625,6 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-1') : undefined, - resolve: id => id === 'agent-1' ? agent : undefined, - }) const first = await openEventClient(ctx, 'events-a') const second = await openEventClient(ctx, 'events-b') const pending = pendingInvocation(agent) @@ -705,14 +673,8 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-rejected') : undefined, - resolve: id => id === 'agent-rejected' ? agent : undefined, - }) const client = await openEventClient(ctx, 'events-rejected') - const pending = pendingInvocation(agent) + const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-rejected')) source.push(pending.dispatch) await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) const frame = deliveredInvocation(client)! @@ -745,12 +707,6 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-1') : undefined, - resolve: id => id === 'agent-1' ? agent : undefined, - }) const first = await openEventClient(ctx, 'events-next-a') const second = await openEventClient(ctx, 'events-next-b') const pending = pendingInvocation(agent) @@ -778,13 +734,7 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-late-client') : undefined, - resolve: id => id === 'agent-late-client' ? agent : undefined, - }) - const pending = pendingInvocation(agent, undefined, 'before-connect') + const pending = pendingInvocation(agent, undefined, 'before-connect', agentId('agent-late-client')) source.push(pending.dispatch) await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) @@ -811,12 +761,6 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-1') : undefined, - resolve: id => id === 'agent-1' ? agent : undefined, - }) const original = await openEventClient(ctx, 'events-original') const pending = pendingInvocation(agent) source.push(pending.dispatch) @@ -848,24 +792,10 @@ describe('Typert Remote streams', () => { const contextFiber = ctx.plugin(() => {}) await contextFiber const contextAgent = contextFiber.ctx - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: (candidate) => { - if (candidate === signalAgent) return agentId('agent-signal') - if (candidate === contextAgent) return agentId('agent-context') - return undefined - }, - resolve: (id) => { - if (id === 'agent-signal') return signalAgent - if (id === 'agent-context') return contextAgent - return undefined - }, - }) const client = await openEventClient(ctx, 'events-cancel') const abort = new AbortController() - const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal') + const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal', agentId('agent-signal')) source.push(signalPending.dispatch) await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) const signalFrame = deliveredInvocation(client)! @@ -886,7 +816,7 @@ describe('Typert Remote streams', () => { }) }) - const contextPending = pendingInvocation(contextAgent, undefined, 'context') + const contextPending = pendingInvocation(contextAgent, undefined, 'context', agentId('agent-context')) source.push(contextPending.dispatch) let contextFrame: RemoteEventInvocationFrame | undefined await vi.waitFor(() => { @@ -899,7 +829,7 @@ describe('Typert Remote streams', () => { && Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined expect(contextFrame).toBeDefined() }) - const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Context "agent" was released') + const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Agent Context was released') await contextFiber.dispose() await contextOutcome await vi.waitFor(() => { diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index b61a6189f8..edbd83dc56 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -1344,7 +1344,6 @@ function contextProvider(context: Context) { return { wire: 'agentId', wireTypeSymbol: '@fixture/domain#AgentId', - identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined, resolve: (id: string) => id === 'agent-1' ? context : undefined, } } diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index b29bc48355..e4d442d64f 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: 1a6c311db9d456db17d942b56cc133799f355a88 -README.zh.md: cba94598868db8401ea512bdb6c274fa2fe098e5 +README.md: 52ef6223f4d7770224df1b1c5d783962c55f73f6 +README.zh.md: eab5d2c6642a7899fac60d8c666ee55057eaea05 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 1a6c311db9..52ef6223f4 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -42,7 +42,7 @@ This package owns no physical transport or Host service discovery. It projects t The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. -The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. +The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. ## Build boundary diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index cba9459886..eab5d2c664 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -42,7 +42,7 @@ Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 Agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 -Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。 +Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 Agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。 ## 构建边界 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 3cfba6343d..3d91c68dc6 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -60,6 +60,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 63b87bd0c5..c75e55eefd 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -2,6 +2,7 @@ import { homedir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' import type { TypertRemoteEventDispatch, TypertRemoteEventInvocation, @@ -57,17 +58,17 @@ function remoteEventSource(ctx: Context): TypertRemoteEventSource { request: object, next: () => unknown, ) { - const subject = carrierKeyOf(this) - if (subject === undefined) return next() - const value = Reflect.get(subject, 'ctx') as unknown - if (typeof value !== 'object' || value === null) { - throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} has no live Context`) + const carrierAgent = carrierKeyOf(this) + if (carrierAgent === undefined) return next() + const agent = (request as { readonly agent?: Agent }).agent + if (agent === undefined || agent !== carrierAgent) { + throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} must carry its Agent directly`) } return forwardWaterfall( queue, event, request, - { value: value as Context, subject }, + { value: agent.ctx, subject: agent, agentId: agent.id }, next, ) }) as never) diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 2e6b0ed7e1..3a7c420409 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -173,10 +173,18 @@ describe('Remote event Host source', () => { const abort = new AbortController() const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() const agentCtx = ctx.extend() - const agent = { ctx: agentCtx } + const agent = { id: 'agent-1', ctx: agentCtx } const target = scopeTarget(ctx, agent) const request = { questions: [], agent } + await expect(async () => waterfallRaw( + ctx, + target, + 'user-questions/request', + [{ questions: [], agent: { id: 'agent-2', ctx: ctx.extend() } }], + () => Promise.resolve('host fallback'), + )).rejects.toThrow('must carry its Agent directly') + const claimed = waterfallRaw( ctx, target, @@ -188,7 +196,7 @@ describe('Remote event Host source', () => { expect(claimedDispatch).toMatchObject({ event: 'user-questions/request', request, - context: { value: agentCtx, subject: agent }, + context: { value: agentCtx, subject: agent, agentId: 'agent-1' }, }) claimedDispatch.resolve({ kind: 'result', value: 'client answer' }) await expect(claimed).resolves.toBe('client answer') @@ -230,7 +238,7 @@ describe('Remote event Host source', () => { const abort = new AbortController() const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() const delivery = iterator.next() - const agent = { ctx: ctx.extend() } + const agent = { id: 'agent-1', ctx: ctx.extend() } const reason = new Error('forwarded event source removed') const pending = waterfallRaw( ctx, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 156a15aa1a..91888af26a 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 7d0785c77395f9eae55656708a15a842a27f079b -README.zh.md: b39a6a512b82cef45550d11660b7a87b2cd0b035 +README.md: ef6ffb35636f9f1a87c3b30832f68540d5dcd0ea +README.zh.md: 97949e01379446e6171debb9fb1250e8088413f8 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 7d0785c773..ef6ffb3563 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -26,6 +26,8 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. +The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provenance, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. + Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame.Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b39a6a512b..97949e0137 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -26,6 +26,8 @@ kind: "package-reference" 历史页与 follow opening snapshot 为每个持久 Session event 携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑 stream 内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 +Client journal 在发布 follow snapshot、live entry 或历史页之前验证精确的 V3 event envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 来源、request header 可选字段的省略规则以及 tool error 一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 + 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 会在解析 Agent 或追加 Session event 前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 19b5339da6..3603490bf4 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index b1f1872662..dd41090c22 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -376,12 +376,14 @@ export class ApiSessionAgentController { readonly setup: AgentSetup }> { const presets = this.ctx.get('agentPresets') - if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } } + if (presets === undefined) { + return { setup: (_agentCtx, agent) => { this.installSelection(agent) } } + } const resolvedId = (await presets.resolve(presetId)).id return { agentPreset: resolvedId, - setup: async (agentCtx) => { - this.installSelection(agentCtx) + setup: async (agentCtx, agent) => { + this.installSelection(agent) await presets.mount(agentCtx, resolvedId) }, } @@ -490,9 +492,7 @@ export class ApiSessionAgentController { return { provider, model } } - private installSelection(agentCtx: Context): void { - const agent = agentCtx.agent - if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent') + private installSelection(agent: Agent): void { this.selectionFor(agent) } diff --git a/packages/api/session-controller/src/client/session-wire-event.ts b/packages/api/session-controller/src/client/session-wire-event.ts new file mode 100644 index 0000000000..ce57f52845 --- /dev/null +++ b/packages/api/session-controller/src/client/session-wire-event.ts @@ -0,0 +1,46 @@ +/** Event-local acceptance for raw Session journal responses; payloads remain owner-defined JSON. */ + +import { validateSessionEventData, validateSurfaceMetadata } from '@deepseek-ai/dsh-session/surface' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionWireEvent } from '../types.ts' + +/** + * Reject non-current event envelopes without stripping or normalizing wire fields. + * Range membership and source existence require the durable log and remain Host-owned. + * @param value - one event received in a follow frame or history page. + * @returns nothing after narrowing the accepted event envelope. + * @throws when the envelope or current event-local metadata is invalid. + */ +export function assertSessionWireEvent(value: unknown): asserts value is SessionWireEvent { + const subject = 'session wire event' + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${subject} must be an object`) + } + const event = value as Record + for (const key of Object.keys(event)) { + switch (key) { + case 'type': + case 'seq': + case 'time': + case 'data': + case 'ignorable': + case 'surfaceOp': + case 'sourceEventSeqs': + break + default: + throw new Error(`${subject} has unexpected field ${key}`) + } + } + const seq = event['seq'] + if (typeof event['type'] !== 'string' + || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || Object.is(seq, -0) + || typeof event['time'] !== 'number' || !Number.isSafeInteger(event['time']) + || !Object.hasOwn(event, 'data') || event['data'] === undefined + || (Object.hasOwn(event, 'ignorable') && event['ignorable'] !== true)) { + throw new Error(`${subject} has an invalid envelope`) + } + // Event names and payloads are merge-extensible; only event-local owner rules run here. + const current = event as unknown as SessionEvent + validateSurfaceMetadata(current) + validateSessionEventData(current, subject) +} diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index 8ba52fb809..7df6362fcf 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -27,6 +27,7 @@ import { } from './sessions/history-records.ts' import type { SessionEventLikeEntry, SessionLiveEventEntry } from './contract/events.ts' import type { SessionRemotes } from './sessions/remotes.ts' +import { assertSessionWireEvent } from './session-wire-event.ts' export { SESSION_SEARCH_RESULT_LIMIT, @@ -181,6 +182,7 @@ export class SessionEventStream extends RemoteJournalStream< ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }), }, signal)) { if (frame.type === 'snapshot') { + for (const record of frame.records) assertSessionWireEvent(record.event) if (frame.assistantStream === undefined) { throw new RemoteError( 'gateway/internal', @@ -212,6 +214,7 @@ export class SessionEventStream extends RemoteJournalStream< yield { type: 'notification', notification: frame.frame } continue } + assertSessionWireEvent(frame.event) yield { type: 'entry', entry: frame } } } @@ -227,6 +230,7 @@ export class SessionEventStream extends RemoteJournalStream< signal, ) if (!result.ok) throw result.error + for (const record of result.value.records) assertSessionWireEvent(record.event) return result.value } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 9444fc694d..607baed3cf 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -407,23 +407,29 @@ export interface SessionWireHeader { readonly agentPreset?: string } -/** Browser wire form of one Session surface operation. */ +/** Browser wire surface operation; replacement endpoints are earlier event seqs in surface order. */ export type SessionWireSurfaceOp = | 'append' - | { readonly op: 'replace'; readonly start: number; readonly end: number } + | { readonly op: 'replace'; readonly startSeq: number; readonly endSeq: number } -/** One history-page record. V2 embeds compact Assistant streams inside events. */ +/** One history-page record with compact Assistant streams embedded inside events. */ export type SessionHistoryRecord = SessionEventEntry -/** Session event wire form; durable readers own recognition of merge-extensible event names. */ +/** + * Exact Session event envelope accepted by the Client journal adapter. + * Surface events require surfaceOp; only non-Assistant surface events may cite earlier sources. + * Durable readers own recognition of merge-extensible event names. + */ export interface SessionWireEvent { readonly type: string readonly seq: number readonly time: number readonly data: JsonValue readonly ignorable?: true - readonly sourceEventSeqs?: number[] - readonly surfaceOp?: SessionWireSurfaceOp + /** Earlier sources on current surface events; opaque JSON on unknown ignorable events. */ + readonly sourceEventSeqs?: JsonValue + /** Canonical placement on current surface events; opaque JSON on unknown ignorable events. */ + readonly surfaceOp?: JsonValue } /** One message-aligned backwards-history request. */ diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index d5b569aa49..586d7b4e7b 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -443,7 +443,7 @@ describe('ApiSession create or adoption', () => { .rejects.toBeInstanceOf(ApiSessionCwdConflict) }) - it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => { + it('surfaces directory creation failure', async () => { const { agents } = await harness() const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-')) tempDirs.push(parent) @@ -451,8 +451,5 @@ describe('ApiSession create or adoption', () => { writeFileSync(file, 'not a directory') await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) .rejects.toThrow('failed to ensure project directory') - - const composition = await agents.composeAgent(undefined) - expect(() => composition.setup(new Context())).toThrow('Agent setup has no scoped Agent') }) }) diff --git a/packages/api/session-controller/tests/assistant-stream.client.spec.ts b/packages/api/session-controller/tests/assistant-stream.client.spec.ts index 3aba529d00..2e1ef4b053 100644 --- a/packages/api/session-controller/tests/assistant-stream.client.spec.ts +++ b/packages/api/session-controller/tests/assistant-stream.client.spec.ts @@ -31,7 +31,7 @@ function messageEvent( seq: number, turn = 1, step = 1, - surfaceOp: 'append' | { readonly op: 'replace'; readonly start: number; readonly end: number } = 'append', + surfaceOp: 'append' | { readonly op: 'replace'; readonly startSeq: number; readonly endSeq: number } = 'append', ): SessionLiveEventEntry { return entry({ type: 'assistant/message', @@ -48,7 +48,7 @@ function messageEvent( }, surfaceOp: surfaceOp === 'append' ? surfaceOp - : { ...surfaceOp, start: SessionSeq(surfaceOp.start), end: SessionSeq(surfaceOp.end) }, + : { ...surfaceOp, startSeq: SessionSeq(surfaceOp.startSeq), endSeq: SessionSeq(surfaceOp.endSeq) }, }) } @@ -127,7 +127,7 @@ describe('ClientAssistantStream', () => { stream.acceptFrame(start(ATTEMPT, 1)) for (const durable of [ ordinary(1), - messageEvent(2, 1, 1, { op: 'replace', start: 0, end: 0 }), + messageEvent(2, 1, 1, { op: 'replace', startSeq: 0, endSeq: 0 }), attemptEvent(0), attemptEvent(3, 2, 1), attemptEvent(4, 1, 2), diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 6cdf3702f9..d7559bc047 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -325,21 +325,24 @@ describe('Session attachment authorization', () => { const message = imageRef('message') const inserted = imageRef('inserted') const streamed = imageRef('streamed') - const events = [ + const events: SessionEvent[] = [ { ...event('fixture/direct', SessionSeq(0), { content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, { type: 'tool-result', content: [{ type: 'image', attachment: nested }], }], }), ignorable: true as const }, - { ...event('assistant/message', SessionSeq(1), { - turn: 1, - step: 1, - stream: [], - message: createAssistantMessage({ - content: [{ type: 'image', attachment: message }], - source: { provider: 'fixture', model: 'fixture' }, - }), - }), surfaceOp: 'append' as const }, + { + type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append', + data: { + turn: 1, + step: 1, + stream: [], + message: createAssistantMessage({ + content: [{ type: 'image', attachment: message }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, + }, event('agent/inbox/spliced', SessionSeq(2), { target: 'next-turn', start: 0, diff --git a/packages/api/session-controller/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts index 59caa3de59..67a7c4026c 100644 --- a/packages/api/session-controller/tests/event-script.client.ts +++ b/packages/api/session-controller/tests/event-script.client.ts @@ -66,8 +66,8 @@ export const ev = { }), codeDispatchStart: (seq: SessionSeq, parentCallId: string, n: number, name: string, args: unknown): SessionEvent => at(seq, { - type: 'tool/code-dispatch-start', - data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args }, + type: 'tool/ptc-dispatch-start', + data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:ptc:${n}`, name, arguments: args }, }), codeDispatch: ( seq: SessionSeq, @@ -79,8 +79,8 @@ export const ev = { isError = false, ): SessionEvent => at(seq, { - type: 'tool/code-dispatch', - data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) }, + type: 'tool/ptc-dispatch', + data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:ptc:${n}`, name, arguments: args, isError, content: text(body) }, }), stepEnd: (seq: SessionSeq, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/end', data: { turn, step } }), @@ -145,7 +145,7 @@ export const ev = { ): SessionEvent => at(seq, { type: 'user/message', - surfaceOp: { op: 'replace', start, end }, + surfaceOp: { op: 'replace', startSeq: start, endSeq: end }, sourceEventSeqs: [summarySeq, start, end], data: createUserMessage({ content: text('model only'), diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index e029ae16c1..1661f505bc 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -37,9 +37,9 @@ async function composed(workspaces: readonly Workspace[] = []): Promise : { inheritedEventCount: options.inheritedEventCount }, }) const agent = {} as Agent - const agentCtx = ownerCtx.extend({ agent }) + const agentCtx = ownerCtx Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx }) - await options.setup?.(agentCtx) + await options.setup?.(agentCtx, agent) ctx.agents.register(agent) return { agent, dispose: () => Promise.resolve() } }, diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index 328b08b1e1..b8bed4b26a 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -770,7 +770,7 @@ describe('Session history raw journal', () => { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }), { - surfaceOp: { op: 'replace', start: shadowedStart, end: shadowedEnd }, + surfaceOp: { op: 'replace', startSeq: shadowedStart, endSeq: shadowedEnd }, sourceEventSeqs: [...shadowed, summary.seq], }) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 4439273e43..fe3ef628b5 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -301,7 +301,7 @@ describe('Web session model selection', () => { id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' }, content: [{ type: 'text', text: 'image summarized' }], } as never, { - surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq }, + surfaceOp: { op: 'replace', startSeq: imageEvent.seq, endSeq: imageEvent.seq }, sourceEventSeqs: [imageEvent.seq], }) ;(agent.inbox.nextTurn as UserMessage[]).push({ diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 0b6e872653..d917ccb777 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -66,9 +66,8 @@ async function harness(presets?: readonly string[]) { options.meta === undefined ? {} : { meta: options.meta }, ) const agent = stubAgent(session) - const agentCtx = ctx.extend({ agent }) - ;(agent as { ctx?: Context }).ctx = agentCtx - await options.setup?.(agentCtx) + ;(agent as { ctx?: Context }).ctx = ctx + await options.setup?.(ctx, agent) const unregister = ctx.agents.register(agent) return { agent, dispose: () => { unregister(); return Promise.resolve() } } }, diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 8ac93433a2..1db4a75c31 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -26,6 +26,7 @@ import type { SessionHistoryRecord, SessionPage, SessionPageRequest, + SessionWireEvent, } from '../src/types.ts' type SessionTransportRemote = Pick @@ -138,7 +139,155 @@ class ScriptedSessionRemote implements SessionTransportRemote { } } +function surfaceEvent(type = 'user/message'): SessionWireEvent { + return { type, seq: 10, time: 10, data: {}, surfaceOp: 'append' } +} + +const invalidWireEvents: [string, unknown][] = [ + ['null event', null], + ['array event', []], + ['extra envelope key', { ...surfaceEvent(), obsolete: true }], + ['unknown ignorable extra envelope key', { type: 'extension/event', seq: 10, time: 10, data: {}, ignorable: true, obsolete: true }], + ['missing data', { type: 'turn/start', seq: 10, time: 10 }], + ['invalid type', { ...surfaceEvent(), type: null }], + ['fractional sequence', { ...surfaceEvent(), seq: 1.5 }], + ['negative sequence', { ...surfaceEvent(), seq: -1 }], + ['negative zero sequence', { ...surfaceEvent(), seq: -0 }], + ['unsafe sequence', { ...surfaceEvent(), seq: Number.MAX_SAFE_INTEGER + 1 }], + ['fractional time', { ...surfaceEvent(), time: 0.5 }], + ['invalid ignorable marker', { ...surfaceEvent(), ignorable: false }], + ...['system/message', 'user/message', 'assistant/message', 'tool/result'].map((type): [string, unknown] => [ + `missing ${type} surface marker`, + { type, seq: 10, time: 10, data: {} }, + ]), + ...['turn/start', 'assistant/attempt', 'request/header', 'request/context', 'session/title', 'extension/event'].flatMap(type => [ + [`non-surface ${type} operation`, { ...surfaceEvent(type) }], + [`non-surface ${type} sources`, { type, seq: 10, time: 10, data: {}, sourceEventSeqs: [0] }], + ] as [string, unknown][]), + ...['turn/start', 'assistant/attempt', 'request/context', 'tool/ptc-dispatch', 'session/title'].flatMap(type => [ + [`known ignorable ${type} operation`, { type, seq: 10, time: 10, data: {}, ignorable: true, surfaceOp: { opaque: true } }], + [`known ignorable ${type} sources`, { type, seq: 10, time: 10, data: {}, ignorable: true, sourceEventSeqs: { opaque: true } }], + ] as [string, unknown][]), + ['assistant sources', { ...surfaceEvent('assistant/message'), sourceEventSeqs: [0] }], + ...[[], [0, 0], [-1], [-0], [0.5], [10], [11], [Number.MAX_SAFE_INTEGER + 1]].map( + (sourceEventSeqs): [string, unknown] => [`invalid sources ${JSON.stringify(sourceEventSeqs)}`, { ...surfaceEvent(), sourceEventSeqs }], + ), + ...[ + null, {}, 'replace', + { op: 'replace', start: 0, end: 1 }, + { op: 'replace', startSeq: 0, end: 1 }, + { op: 'replace', start: 0, endSeq: 1 }, + { op: 'replace', startSeq: 0, endSeq: 1, start: 0, end: 1 }, + { op: 'replace', startSeq: 0, endSeq: 1, extra: true }, + { op: 'replace', startSeq: 0 }, + { op: 'replace', endSeq: 1 }, + ...[-1, -0, 0.5, 10, Number.MAX_SAFE_INTEGER + 1].flatMap(seq => [ + { op: 'replace', startSeq: seq, endSeq: 1 }, + { op: 'replace', startSeq: 0, endSeq: seq }, + ]), + ].map((surfaceOp, index): [string, unknown] => [`invalid replacement ${index}`, { ...surfaceEvent(), surfaceOp }]), + ...[{ system: '' }, { system: ' ' }, { system: 'prompt' }, { system: null }, { system: {} }, { tools: [] }, { adapterDefaults: {} }].map((optional): [string, unknown] => [ + `empty request header ${JSON.stringify(optional)}`, + { type: 'request/header', seq: 10, time: 10, data: { + reason: 'initial', header: { config: { provider: 'mock', model: 'mock' }, ...optional }, + } }, + ]), + ...[false, undefined].map((isError): [string, unknown] => [ + `contradictory tool error ${String(isError)}`, + { ...surfaceEvent('tool/result'), data: { + message: { content: [{ type: 'tool-result', content: [], ...(isError === undefined ? {} : { isError }) }] }, + error: { name: 'Error', code: 'FAILURE' }, + } }, + ]), +] + +describe.each(['snapshot', 'live', 'page'] as const)('Session %s wire acceptance', (path) => { + it.each(invalidWireEvents)('refuses %s without publishing or retrying', async (_name, event) => { + // The Remote mock is the decoded JSON transport, not a typed same-process producer. + const record = { type: 'event', event } as SessionHistoryRecord + const opening = snapshot(path === 'live' ? 9 : 11, [entry(path === 'live' ? 9 : 11)]) + const remote = new ScriptedSessionRemote([{ + frames: path === 'snapshot' ? [snapshot(10, [record])] + : path === 'live' ? [opening, record] : [opening], + hold: true, + }], path === 'page' ? [{ ok: true, value: page([record]) }] : []) + const publish = vi.fn() + const failed = vi.fn() + const carrierFailed = vi.fn() + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { publish, failed, carrierFailed }) + try { + if (path === 'snapshot') { + await expect(stream.open({})).rejects.toThrow() + expect(publish).not.toHaveBeenCalled() + } else { + await stream.open({}) + if (path === 'page') await expect(stream.prepend({})).rejects.toThrow() + else await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() }) + expect(publish).toHaveBeenCalledOnce() + } + expect(remote.followRequests).toHaveLength(1) + expect(carrierFailed).not.toHaveBeenCalled() + } finally { + await stream.dispose() + } + }) +}) + describe('Session Client stream adapters', () => { + it('preserves current envelopes and payloads without normalization across every journal path', async () => { + const events: SessionWireEvent[] = [ + surfaceEvent(), + surfaceEvent('system/message'), + { ...surfaceEvent('system/message'), surfaceOp: { op: 'replace', startSeq: 2, endSeq: 2 }, sourceEventSeqs: [2], data: { message: { source: { plugin: 'system', extra: true }, content: [] }, extra: { retained: true } } }, + { ...surfaceEvent(), sourceEventSeqs: [0, 2] }, + { ...surfaceEvent(), surfaceOp: { op: 'replace', startSeq: 2, endSeq: 0 }, sourceEventSeqs: [2, 0] }, + { ...surfaceEvent('assistant/message'), data: { turn: 1, step: 1, message: {}, stream: [] } }, + { ...surfaceEvent('tool/result'), data: { + message: { content: [{ type: 'tool-result', content: [], isError: true }] }, + error: { name: 'Error', code: 'FAILURE' }, meta: { extension: ['retained'] }, + } }, + { ...surfaceEvent('tool/result'), sourceEventSeqs: [0], data: { + message: { content: [{ type: 'tool-result', content: [], isError: true }] }, + } }, + { type: 'request/header', seq: 10, time: 10, data: { + reason: 'initial', header: { config: { provider: 'mock', model: 'mock' } }, + } }, + { type: 'request/header', seq: 10, time: 10, data: { + reason: 'change', header: { + config: { provider: 'mock', model: 'mock' }, extension: { nested: ['retained'] }, + tools: [{ name: 'fixture' }], adapterDefaults: { temperature: 1 }, + }, + } }, + ...['extension/event', 'tool/code-dispatch', 'tool/code-dispatch-start'].map(type => ({ + type, seq: 10, time: 10, data: { nested: [null, true] }, ignorable: true, + surfaceOp: { opaque: ['retained'] }, sourceEventSeqs: { opaque: [null] }, + }) as unknown as SessionWireEvent), + ] + for (const event of events) { + const before = structuredClone(event) + const record: SessionHistoryRecord = { type: 'event', event } + const remote = new ScriptedSessionRemote([{ + frames: [snapshot(10, [record]), { type: 'event', event: { ...event, seq: 11 } }], hold: true, + }], [{ ok: true, value: page([{ type: 'event', event: { ...event, seq: 9 } }]) }]) + const changes: SessionJournalChange[] = [] + let appended!: () => void + const ready = new Promise((resolve) => { appended = resolve }) + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: (change) => { changes.push(change); if (change.type === 'append') appended() }, + failed: vi.fn(), + }) + try { + await stream.open({}) + await ready + await stream.prepend({}) + expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'prepend']) + expect(changes[0]).toMatchObject({ page: { records: [record] } }) + expect(event).toEqual(before) + } finally { + await stream.dispose() + } + } + }) it('opts into assistant notifications and publishes the reconnect baseline plus live frame', async () => { const attemptId = LlmAttemptId('transport-attempt') const baseline: SessionAssistantStreamBaseline = { @@ -466,7 +615,7 @@ describe('Session Client stream adapters', () => { await stream.dispose() }) - it('repairs a live gap without adding an absent message limit', async () => { + it.each([{}, { maxMessages: 50 }])('repairs a live gap preserving message limit %j', async (request) => { const remote = new ScriptedSessionRemote( [{ frames: [snapshot(0, [entry(0)]), entry(2)], hold: true }], [{ ok: true, value: page([entry(0), entry(1), entry(2)]) }], @@ -477,10 +626,13 @@ describe('Session Client stream adapters', () => { failed: vi.fn(), }) - await stream.open({}) - await vi.waitFor(() => { expect(changes).toHaveLength(2) }) - expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: 2 }]) - await stream.dispose() + try { + await stream.open(request) + await vi.waitFor(() => { expect(changes).toHaveLength(2) }) + expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: 2, ...request }]) + } finally { + await stream.dispose() + } }) it('turns a pagination failure into a typed stream failure', async () => { diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 5dc25967ad..ebc731769e 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -707,7 +707,7 @@ describe('SessionHistoryController', () => { append(session, 'assistant/message', { turn: 1, step: 2, message: {} }, { surfaceOp: 'append' }) const summary = append(session, 'fixture/summary', {}) const replacement = append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, { - surfaceOp: { op: 'replace', start: SessionSeq(1), end: SessionSeq(4) }, + surfaceOp: { op: 'replace', startSeq: SessionSeq(1), endSeq: SessionSeq(4) }, sourceEventSeqs: [SessionSeq(1), firstReply.seq, SessionSeq(3), SessionSeq(4), summary.seq], }) diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 550d09b852..2cfeb1e89a 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 1921b183d6..fcfd014594 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 3d4b3525e8..3846553f32 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: ec15f52bf17fffca2b225fa427a7405922aaf2b3 -README.zh.md: 7e45d5c9a6073435e26e1237791a328db654d839 +README.md: e49f5bef7291233eb688bc9aeab44e56efb3d7fa +README.zh.md: 1d3808969ac377408801a7516978bbebd6860b62 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index ec15f52bf1..e49f5bef72 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -1,5 +1,5 @@ --- -description: "Workspace file service for the web GUI: paged read, byte windows, stat, directory listing, and the Agent-write change feed inside the Session workspace root, exposed as the workspaceFiles Remote namespace." +description: "Workspace file service for the web GUI: bounded file reads through the composed filesystem, plus directory listing and Agent-write observation inside the Session workspace root." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this package to browse and inspect files within a Session's workspace from the web client. It reads UTF-8 text one page of lines at a time, reads raw bytes in bounded windows, reports file versions and sizes, lists direct directory children, and streams changes caused by Agent file operations. Every operation stays within the workspace root selected for the addressed Session, independent of the filesystem backend's working directory. Client components can also follow live file metadata and build the Sidebar file tree through the shared Remote API. +Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. File reads may target paths outside the workspace; directory listing and Agent-write change observation remain workspace-scoped. The service exposes no mutation operation. ## Table of Contents @@ -32,12 +32,14 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway; | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | Identity, version, and size of one regular file, without content | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | One window of lines from a UTF-8 text file; `lines` counts them, so one empty line and a page past the end read differently | | `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | One window of raw bytes from any regular file, base64-encoded | +| `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated | +| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory | | `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then Agent observations inside the workspace root | ### Addressing and paths -`read`, `stat`, and `list` accept a workspace path that is absolute or relative to the Session's workspace root. Two path vocabularies leave the service, and each method uses exactly one: `read`, `stat`, and `changes` report a file as its absolute path in the filesystem's execution world, symlinks resolved (`WorkspaceFileStat.absolutePath`, `WorkspaceFileChange.absolutePath`), because their consumer is the Client resource system, which follows changes by that path; `list` reports the listed directory as a workspace path relative to the root — empty for the root itself — because its consumer is a tree rooted there, and a child's path is that value joined with the entry name by `/`. +`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` likewise reports only Agent observations inside the workspace root. ### Pages @@ -47,9 +49,9 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway; `read` pages by lines and never by bytes; a byte window is `readBytes`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest number of bytes in the window and defaults to `maxBytes`, which it may not exceed — a longer window fails with `too-large` instead of arriving shortened, and an offset or length that is not an integer in range is a `gateway/bad-request`. The window comes back as base64 `data`, shorter than `length` at the end of the file and empty at or past it; `eof` is true when the window includes the file's last byte. Nothing is decoded and nothing is refused as binary, so an image or a NUL-laden file reads where `read` fails with `not-text`. The same `version` and `bytes` ride along as on a page. -### The four gates +### File-read and directory checks -Every read, stat, and listing passes four gates in this order. First, `lstat` inspects the path itself before anything follows it: a symlink, wherever it points, fails `read` and `stat` with `not-regular-file` and `list` with `not-directory`, each carrying the entry's `kind`. Second, containment: the path resolves to a target and `ctx.fs.contains(root, target)` decides, so a `..` traversal or an absolute path outside the root fails with `outside-workspace` — never a string-prefix comparison, which cannot see a realpath that leaves the root. Third, the caps: a page whose text exceeds `maxBytes` fails with `too-large` instead of arriving shortened — the file itself has no size cap — while `maxEntries` cuts a listing and sets `truncated`. Fourth, text: content that is not UTF-8 up to the end of the page, or a page that carries a NUL byte, fails with `not-text`; bytes past the page are not inspected. A missing path fails with `not-found`; an empty path is a `gateway/bad-request`. +Every operation first uses `lstat` to reject a missing path, a final symlink, or the wrong file kind. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. `list` alone requires the resolved directory to remain inside the workspace root. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty path is a `gateway/bad-request`. ### The change feed @@ -60,6 +62,7 @@ Every read, stat, and listing passes four gates in this order. First, `lstat` in | Field | Default | Meaning | |---|---|---| | `maxBytes` | `2097152` (2 MiB) | Inclusive byte cap on one page's text and on one byte window; a larger page or window fails | +| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readAll` and `readRelated`; larger files fail with `too-large` | | `maxLines` | `5000` | Default and largest page size in lines; a larger `limit` is refused | | `maxEntries` | `2000` | Cap on returned directory entries; the rest is dropped and reported cut | @@ -67,15 +70,15 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Failures -Each failure is one `RemoteError` code with typed details, declared in [`src/types.ts`](src/types.ts): `workspace-file/not-found`, `workspace-file/outside-workspace`, `workspace-file/too-large` (with `limit`, the page and window cap), `workspace-file/not-text`, `workspace-file/not-regular-file` (`kind`: `directory`, `symlink`, or `other`), and `workspace-file/not-directory` (`kind`: `file`, `symlink`, or `other`). Callers branch on the code, never on message text. +Each failure is one `RemoteError` code with typed details, declared in [`src/types.ts`](src/types.ts): `workspace-file/not-found`, `workspace-file/outside-workspace` (directory listing only), `workspace-file/too-large` (with `limit`, the applicable page, window, or complete-file cap), `workspace-file/not-text`, `workspace-file/not-regular-file` (`kind`: `directory`, `symlink`, or `other`), and `workspace-file/not-directory` (`kind`: `file`, `symlink`, or `other`). Callers branch on the code, never on message text. ### Client file resources -The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, `remote.workspaceFiles`, and `sessions`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component follows a file through its standard `useResource<'file'>(address)` prop and reads `{ absolutePath, version, bytes?, changed }`; content is fetched separately through the paged methods. +The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, and `remote.workspaceFiles`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component reads `WorkspaceFileStat { absolutePath, version, bytes? }` metadata through `useResource<'file'>(address)` and fetches content separately through Remote reads. Any UI, including Global components, shares the observation for the same complete address. -A `session//` resource address sends its relative path unchanged to the Host, which resolves and confines it against that Session's workspace root; the Client needs no Session `cwd`. An `absolute/` address reads through the current Session. Both use the `dsh-resource://file/` grammar in [workspace-path](../../util/workspace-path/README.md). An absolute address without a current Session produces `workspace-file/unknown-workspace`; an unsupported address produces `workspace-file/unsupported-address`. These Client failures end the stream and make reload a no-op. +A `session//` address carries the authorizing Session and a relative or absolute path; leading slashes are preserved, as in `dsh-resource://file/session/s//etc/hosts`. The Host receives the path unchanged and owns resolution and access checks; the Client needs no Session `cwd`. `absolute/` remains parseable but has no authorizing Session and fails with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Unsupported addresses fail with `workspace-file/unsupported-address`. [Workspace-path](../../util/workspace-path/README.md) owns the grammar; the generic Resource layer knows only the address and `signal`. -The provider waits for the Host's `ready` frame before its first `stat`, queues changes during the read, then binds the follower to `stat.absolutePath`. Both queued and live changes match that Host-returned path. A new write version raises `changed` while retaining the last byte size; duplicate versions are ignored. An absent notice or reload re-stats the file. A failed stat keeps the address followed; a later write or reload can recover it, and any Session write can trigger a retry before the first successful path binding. Reload clears `changed`; a Host-triggered re-stat keeps it raised. Frames are `RemoteResult` values, and programming exceptions remain uncaught. +The provider waits for the Host's `ready` frame before its first `stat`, queues changes during the read, then binds the follower to `stat.absolutePath`. Both queued and live changes match that Host-returned path. A new write version updates metadata while retaining the last byte size; duplicate versions are ignored. An absent notice re-stats the file. A failed stat keeps the address followed; a later write can recover it, and any Session write can trigger a retry before the first successful path binding. Frames are `RemoteResult` values, and programming exceptions remain uncaught. One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters. @@ -89,13 +92,13 @@ One supervised `changes` stream serves every followed file in a Session. Followe ### Design concept -Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend fences writes and edits only — so every constraint here is the service's own. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window, so neither a huge file nor one giant line can hold more than a page in memory; the NUL scan then runs on the page. One `stat` before the stream names the version and size the page reports. The path gate runs before containment on purpose: `lstat` is path-shaped and sees the link, while `resolve` follows it; the price is that an entry outside the root reports its own kind before its position. +Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `stat`, `list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `readAll`, `readRelated`, `stat`, `list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: `fs/observed` subscription and one queue per open `changes` generation | | [`src/types.ts`](src/types.ts) | Wire types and the `RemoteErrorDetailsMap` codes, published as `./types` for Client packages | | [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-Session change feed | @@ -116,7 +119,7 @@ Typert generates the Host and Client Remote artifacts exposed by `./typert` and - [Remote assembly](../../api/remotes/README.md) — how Client packages reach the `workspaceFiles` namespace. - [Client resources](../../client/resources/README.md) — the resource model, `useResource`, pins, and provider lifetime. - [Workspace path helpers](../../util/workspace-path/README.md) — `fileAddressFor` and `parseFileAddress`, the `dsh-resource://file/…` address grammar both ends share. -- [Sidebar text preview](../../client/ui-sidebar-textpreview/README.md) — the tab type that follows a file through the `file` provider and reads its pages. +- [Sidebar text preview](../../client/ui-sidebar-documentpreview/README.md) — the tab type that follows a file through the `file` provider and reads its pages. ----- @@ -134,14 +137,13 @@ None; this package neither assembles nor sends a provider request. - **Agent writes only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame. -- **Kind before position** — an entry outside the workspace whose type already disqualifies it reports `not-regular-file` or `not-directory`, not `outside-workspace`, because the path gate precedes containment. +- **Directory scope only** — `list` and `changes` stay inside the Session workspace even though file preview reads may use any path readable by the filesystem backend. - **No total line count** — a page reports `eof`, not how many lines follow; a consumer that needs the total pages to the end or estimates from `bytes`. - **One giant line has no page** — a single line above `maxBytes` fails `too-large` at every window that includes it, because pages are cut by lines, not bytes. -- **Version precedes content** — the `version` on a page is the stat's, taken before the stream; a write landing between the two leaves the page one version behind, which the next `changes` frame reports. +- **Reads are not transactional** — result metadata comes from stat before content is read; a concurrent write can make the reported version and returned contents differ. - **Unbounded generation queue** — a `changes` generation buffers every contained observation until its consumer pulls; a stalled consumer grows Host memory for the life of the stream. - **`maxEntries` bounds the answer, not the listing** — `list` asks `ctx.fs.listDir` for every child and cuts the array afterwards, so a directory far above the cap still costs the Host the whole listing (on `fs-local`, one stat per child); bounding that work needs a limit on the filesystem seam's `listDir`. -- **Dead feeds retain metadata** — after the Host ends `changes` or the stream fails terminally, open values retain their last state until reopened; reload does not reopen the stream. -- **Reload is shared by path** — a reload re-stats every follower of that absolute path in the Session and clears their `changed` flags, including readers that did not reload their content. Per-record reload delivery remains deferred. +- **Dead feeds retain metadata** — after the Host ends `changes` or the stream fails terminally, open values retain their last state until reopened. ### Dev Note diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 7e45d5c9a6..1d3808969a 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向 Web GUI 的工作区文件服务:在 Session 工作区根内做分页读取、字节窗口、stat、目录列举与 Agent 写入变更流,以 workspaceFiles Remote 命名空间暴露。" +description: "面向 Web GUI 的工作区文件服务:通过组合文件系统进行有界文件读取,并在 Session 工作区根内列举目录和观察 Agent 写入。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用本包可从 Web Client 浏览和检查 Session 工作区内的文件。它按行分页读取 UTF-8 文本、按有界窗口读取原始字节、报告文件版本与大小、列举目录的直接子项,并流式推送 Agent 文件操作造成的变更。每项操作都限定在为被寻址 Session 选择的工作区根内,不受文件系统后端工作目录影响。Client 组件还可经共享 Remote API 跟随实时文件元数据并构建 Sidebar 文件树。 +使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。文件读取可以指向工作区外路径;目录列举与 Agent 写入变更观察仍限定于工作区。本服务不提供修改操作。 ## 目录 @@ -32,12 +32,14 @@ kind: "package-reference" | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | 一个普通文件的身份、版本与大小,不含内容 | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | UTF-8 文本文件的一个行窗口;`lines` 计行数,使单个空行与越过文件末尾的页可区分 | | `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 任意普通文件的一个原始字节窗口,base64 编码 | +| `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 | +| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 | | `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的 Agent 观察 | ### 寻址与路径 -`read`、`stat` 与 `list` 接受工作区路径,可以是绝对路径,也可以是相对于 Session 工作区根的路径。离开服务的路径词汇有两套,每个方法只用其中一套:`read`、`stat` 与 `changes` 以文件系统执行环境中的绝对路径报告文件,符号链接已解析(`WorkspaceFileStat.absolutePath`、`WorkspaceFileChange.absolutePath`),因为其消费方是 Client 资源系统,它按这条路径跟随变更;`list` 以相对于根的工作区路径报告被列举目录——根自身为空串——因为其消费方是一棵以根为起点的树,子项路径就是该值与条目名以 `/` 连接。 +`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 同样只报告工作区根内的 Agent 观察。 ### 分页 @@ -47,9 +49,9 @@ kind: "package-reference" `read` 按行分页,绝不按字节;字节窗口走 `readBytes`。`range.offset` 是 0 起算的首字节,缺省为 0;`range.length` 是窗口最多的字节数,缺省为 `maxBytes` 且不得超过它——更长的窗口以 `too-large` 失败而不是被截短,不是整数或越界的 offset / length 则是 `gateway/bad-request`。窗口以 base64 的 `data` 返回,到文件末尾时短于 `length`,位于或越过末尾时为空;窗口含文件最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝,因此图片或含 NUL 的文件在 `read` 以 `not-text` 失败之处仍可读出。与页一样附带同一 `version` 与 `bytes`。 -### 四道关 +### 文件读取与目录检查 -每次读取、stat 与列举依次过四道关。第一,`lstat` 在跟随任何东西之前检查路径本身:符号链接不论指向哪里,`read` 与 `stat` 都以 `not-regular-file`、`list` 都以 `not-directory` 拒绝,并带上条目的 `kind`。第二,包含判定:路径解析为目标后由 `ctx.fs.contains(root, target)` 裁决,所以 `..` 上溯或根外绝对路径都以 `outside-workspace` 失败——绝不做字符串前缀比较,那看不见离开根的 realpath。第三,上限:文本超过 `maxBytes` 的页以 `too-large` 失败而不是被截短送达——文件本身没有大小上限——`maxEntries` 则截断列举并置 `truncated`。第四,文本:到该页末尾为止非 UTF-8 的内容,或含 NUL 字节的页,以 `not-text` 失败;页之后的字节不检查。路径不存在以 `not-found` 失败;空路径是 `gateway/bad-request`。 +每项操作都先通过 `lstat` 拒绝不存在的路径、末端符号链接或错误的文件类型。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。只有 `list` 要求解析后的目录仍位于工作区内。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。空路径是 `gateway/bad-request`。 ### 变更流 @@ -60,6 +62,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `maxBytes` | `2097152`(2 MiB) | 单页文本与单个字节窗口的字节上限(含);更大的页或窗口失败 | +| `maxFileBytes` | `33554432`(32 MiB) | `readAll` 和 `readRelated` 的完整文件字节上限(含);更大文件以 `too-large` 失败 | | `maxLines` | `5000` | 页大小的缺省值与上限(行);更大的 `limit` 被拒绝 | | `maxEntries` | `2000` | 返回目录条目数上限;其余丢弃并报告截断 | @@ -67,15 +70,15 @@ kind: "package-reference" ### 失败 -每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明于 [`src/types.ts`](src/types.ts):`workspace-file/not-found`、`workspace-file/outside-workspace`、`workspace-file/too-large`(带 `limit`,即页与窗口上限)、`workspace-file/not-text`、`workspace-file/not-regular-file`(`kind` 为 `directory`、`symlink` 或 `other`)以及 `workspace-file/not-directory`(`kind` 为 `file`、`symlink` 或 `other`)。调用方按代码分支,绝不按消息文本。 +每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明于 [`src/types.ts`](src/types.ts):`workspace-file/not-found`、`workspace-file/outside-workspace`(仅目录列举)、`workspace-file/too-large`(带 `limit`,即适用的页、窗口或完整文件上限)、`workspace-file/not-text`、`workspace-file/not-regular-file`(`kind` 为 `directory`、`symlink` 或 `other`)以及 `workspace-file/not-directory`(`kind` 为 `file`、`symlink` 或 `other`)。调用方按代码分支,绝不按消息文本。 ### Client 文件资源 -浏览器导出向 `ctx.resources` 注册 `file` 提供者,要求 `resources`、`remote`、`remote.workspaceFiles` 和 `sessions` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件经标准 prop `useResource<'file'>(address)` 跟随文件,读取 `{ absolutePath, version, bytes?, changed }`;内容通过分页方法另行获取。 +浏览器导出向 `ctx.resources` 注册 `file` 提供方,要求 `resources`、`remote` 和 `remote.workspaceFiles` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件通过 `useResource<'file'>(address)` 读取 `WorkspaceFileStat { absolutePath, version, bytes? }` 元数据,内容另经 Remote 读取。任何 UI(包括 Global)访问同一完整地址都共享观察。 -`session//` 资源地址把相对路径原样发送给 Host,由 Host 按该 Session 的工作区根解析并检查包含关系;Client 不需要 Session `cwd`。`absolute/` 地址经当前 Session 读取。两者都使用[workspace-path](../../util/workspace-path/README.zh.md)规定的 `dsh-resource://file/` 语法。没有当前 Session 的绝对地址产生 `workspace-file/unknown-workspace`;不支持的地址产生 `workspace-file/unsupported-address`。这些 Client 失败会结束流,并使刷新无动作。 +`session//` 地址携带授权 Session,以及相对或绝对路径;前导斜杠保留,例如 `dsh-resource://file/session/s//etc/hosts`。Host 原样接收路径,负责解析与权限检查;Client 不需要 Session `cwd`。`absolute/` 仍可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。不支持的地址以 `workspace-file/unsupported-address` 失败。语法由 [workspace-path](../../util/workspace-path/README.zh.md) 定义;Resource 泛型层只认地址和 `signal`。 -提供者等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新的写入版本置 `changed`,并保留最近的字节大小;重复版本被忽略。消失通知或刷新会重新 stat 文件。stat 失败后仍跟随地址,后续写入或刷新可使其恢复;首次成功绑定路径前,Session 内任何写入都可触发重试。刷新清除 `changed`,由 Host 触发的重新 stat 保留标记。帧是 `RemoteResult` 值,编程异常不被捕获。 +提供者等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新的写入版本更新元数据并保留最近的字节大小;重复版本被忽略。消失通知会重新 stat 文件。stat 失败后仍跟随地址,后续写入可使其恢复;首次成功绑定路径前,Session 内任何写入都可触发重试。帧是 `RemoteResult` 值,编程异常不被捕获。 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 @@ -89,13 +92,13 @@ kind: "package-reference" ### 设计概念 -经 `ctx.fs` 的读取是有意不加限制的——沙箱后端只围栏写与编辑——所以这里的每条约束都是服务自己的。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回,所以无论多大的文件或多长的单行都不会在内存里超过一页;随后在该页上做 NUL 扫描。流之前的一次 `stat` 给出页所报告的版本与大小。路径关有意先于包含判定:`lstat` 面向路径、看得见链接,而 `resolve` 会跟随它;代价是根外条目会先报告自己的类型再报告位置。 +经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`stat`、`list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`readAll`、`readRelated`、`stat`、`list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:`fs/observed` 订阅与每个打开的 `changes` generation 各一条队列 | | [`src/types.ts`](src/types.ts) | 线路类型与 `RemoteErrorDetailsMap` 错误码,以 `./types` 发布给 Client 包 | | [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与每 Session 变更流 | @@ -116,7 +119,7 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物 - [Remote 装配](../../api/remotes/README.zh.md)——Client 包如何触达 `workspaceFiles` 命名空间。 - [Client 资源](../../client/resources/README.zh.md)——资源模型、`useResource`、pin 与提供者生命周期。 - [工作区路径辅助](../../util/workspace-path/README.zh.md)——`fileAddressFor` 与 `parseFileAddress`,两端共享的 `dsh-resource://file/…` 地址语法。 -- [Sidebar 文本预览](../../client/ui-sidebar-textpreview/README.zh.md)——经 `file` 提供者跟随文件并读取其页的 tab 类型。 +- [Sidebar 文本预览](../../client/ui-sidebar-documentpreview/README.zh.md)——经 `file` 提供者跟随文件并读取其页的 tab 类型。 ----- @@ -134,14 +137,13 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物 - **仅覆盖 Agent 写入**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。 -- **类型先于位置**——根外条目若类型本身就不合格,报告的是 `not-regular-file` 或 `not-directory` 而非 `outside-workspace`,因为路径关先于包含判定。 +- **仅目录受限**——尽管文件预览可以读取文件系统后端允许的任意路径,`list` 与 `changes` 仍限定在 Session 工作区内。 - **没有总行数**——页只报告 `eof`,不报告后面还有多少行;需要总数的消费方要翻到末尾或按 `bytes` 估算。 - **超长单行没有页**——超过 `maxBytes` 的单行在包含它的每个窗口都以 `too-large` 失败,因为页按行而非按字节切。 -- **版本先于内容**——页上的 `version` 来自流之前的 stat;两者之间落地的写入会让该页落后一个版本,下一帧 `changes` 会报告它。 +- **读取不具备事务性**——结果元数据来自内容读取之前的 stat;并发写入可能使报告版本与返回内容不一致。 - **generation 队列无界**——一个 `changes` generation 会缓冲每一条被包含的观察直到消费方 pull;停滞的消费方会在流的生命期内持续增长 Host 内存。 - **`maxEntries` 限制的是答案,不是列举**——`list` 让 `ctx.fs.listDir` 列出全部子项后再截断数组,远超上限的目录仍让 Host 付出整个列举的代价(`fs-local` 上每个子项一次 stat);要限制这份工作,需要文件系统 seam 的 `listDir` 支持上限。 -- **失效流保留元数据**——Host 结束 `changes` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开;刷新不会重开流。 -- **刷新按路径共享**——同一会话中,一次刷新会重新 stat 此绝对路径的全部跟随者并清除其 `changed` 标记,包括没有重读内容的其它读者。按记录投递刷新仍是延期工作。 +- **失效流保留元数据**——Host 结束 `changes` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开。 ### 开发备注 diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index b51b6e9594..ab886d9e57 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -41,7 +41,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-gateway", - "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-resources" ], "platform": "web" @@ -65,7 +64,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", diff --git a/packages/api/workspace-files/src/client/change-feed.ts b/packages/api/workspace-files/src/client/change-feed.ts index cc587d5f1f..39f43c3940 100644 --- a/packages/api/workspace-files/src/client/change-feed.ts +++ b/packages/api/workspace-files/src/client/change-feed.ts @@ -7,8 +7,7 @@ * arrives, hands each frame to the followers of its path, and disposes the * stream when the last follower leaves. A follower buffers session changes * until `stat` supplies its Host absolute path, then filters queued and live - * frames by that path, with `\\` normalized to `/`. Resource addresses identify - * reload requests; they never determine a notification path. + * frames by that path, with `\\` normalized to `/`. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceFileChange, WorkspaceFileWatchFrame } from '../types.ts' @@ -26,7 +25,7 @@ function keyOf(path: string): string { /** Notices of one follower, delivered in order and pulled by its consumer. */ class Follower implements AsyncIterable { - private readonly pending: Array<{ readonly key: string | undefined; readonly notice: WorkspaceFileNotice }> = [] + private readonly pending: Array<{ readonly key: string; readonly notice: WorkspaceFileNotice }> = [] private readonly started = Promise.withResolvers() private wake: (() => void) | undefined private ended = false @@ -39,15 +38,9 @@ class Follower implements AsyncIterable { readonly ready = this.started.promise /** - * @param address - resource address used for reload lookup. * @param leave - unregisters this follower and its abort listener. */ - constructor(readonly address: string, private readonly leave: () => void) {} - - /** The normalized Host path, absent until a successful stat. */ - get key(): string | undefined { - return this.hostKey - } + constructor(private readonly leave: () => void) {} /** * Select the Host path for queued and future changes. @@ -65,9 +58,9 @@ class Follower implements AsyncIterable { /** * Queue one notice. * @param notice - what the consumer receives next. - * @param key - normalized Host path for a change; absent for a reload. + * @param key - normalized Host path for the change. */ - push(notice: WorkspaceFileNotice, key?: string): void { + push(notice: WorkspaceFileNotice, key: string): void { this.pending.push({ key, notice }) this.wake?.() } @@ -90,7 +83,7 @@ class Follower implements AsyncIterable { while (true) { const next = this.pending.shift() if (next !== undefined) { - if (next.key === undefined || this.hostKey === undefined || next.key === this.hostKey) yield next.notice + if (this.hostKey === undefined || next.key === this.hostKey) yield next.notice continue } if (this.ended) return @@ -155,22 +148,6 @@ class SessionFeed { if (this.followers.size === 0) this.close() } - /** - * Reload an address and every follower bound to the same Host path. - * @param address - the resource address requesting a reload. - */ - requestRestat(address: string): void { - const keys = new Set() - for (const follower of this.followers) { - if (follower.address === address && follower.key !== undefined) keys.add(follower.key) - } - for (const follower of this.followers) { - if (follower.address === address || (follower.key !== undefined && keys.has(follower.key))) { - follower.push({ kind: 'restat' }) - } - } - } - private async pump(): Promise { try { for await (const item of this.stream) { @@ -250,7 +227,7 @@ export class ChangeFeed { constructor(private readonly remote: WorkspaceFilesRemote) {} /** - * Follow one resource address in one session before its Host path is known. + * Follow one resource in one session before its Host path is known. * * The follower is registered on call, not on first pull. Changes delivered * to this Client are queued while stat is pending. The first follower starts @@ -262,18 +239,17 @@ export class ChangeFeed { * any session write can trigger a retry; after binding, only matching queued * and live changes pass. * @param sessionId - the session whose workspace holds the file. - * @param address - the resource address, used only for reload lookup. * @param signal - ends the follow. * @returns a single-consumer subscription with Host-path binding and explicit disposal. */ - follow(sessionId: SessionId, address: string, signal: AbortSignal): Follower { + follow(sessionId: SessionId, signal: AbortSignal): Follower { const feed = signal.aborted ? undefined : this.feedOf(sessionId) const leave = (): void => { signal.removeEventListener('abort', leave) follower.end() feed?.remove(follower) } - const follower = new Follower(address, leave) + const follower = new Follower(leave) if (feed === undefined) { follower.end() } else { @@ -283,15 +259,6 @@ export class ChangeFeed { return follower } - /** - * Ask an address and its same-session Host-path peers to `stat` again. - * @param sessionId - the session whose workspace holds the file. - * @param address - the resource address requesting a reload. - */ - requestRestat(sessionId: SessionId, address: string): void { - this.sessions.get(sessionId)?.requestRestat(address) - } - /** * Wait for every stream that is still closing, so an owner tearing down * leaves no Host stream behind. diff --git a/packages/api/workspace-files/src/client/index.ts b/packages/api/workspace-files/src/client/index.ts index 418cd5fcf5..687580a7a3 100644 --- a/packages/api/workspace-files/src/client/index.ts +++ b/packages/api/workspace-files/src/client/index.ts @@ -7,29 +7,22 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-gateway/client' -// The `sessions` service face supplies the current Session for absolute addresses. -import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type {} from '@deepseek-ai/dsh-client-resources/client' import { ChangeFeed } from './change-feed.ts' -import { createFileResourceProvider, type SessionLookup } from './provider.ts' +import { createFileResourceProvider } from './provider.ts' -export type { SessionLookup } from './provider.ts' -export type { WorkspaceFileParams, WorkspaceFileResource } from './types.ts' +export type { WorkspaceFileParams } from './types.ts' -/** Required browser services: the resource model, the Remote carrier and its namespace, and the Session list. */ -export const inject = ['resources', 'remote', 'remote.workspaceFiles', 'sessions'] +/** Required browser services: the resource model, the Remote carrier and its namespace. */ +export const inject = ['resources', 'remote', 'remote.workspaceFiles'] /** * Client plugin body: register the `file` provider for this plugin's lifetime. - * @param ctx - client root context carrying `resources`, the Remote face, and `sessions`. + * @param ctx - client root context carrying `resources` and the Remote face. */ export function apply(ctx: ClientContext): void { - // The current Session changes with navigation; absolute addresses read it on demand. - const sessions: SessionLookup = { - current: () => ctx.sessions.list.getSnapshot().current, - } const changes = new ChangeFeed(ctx.remote) - const provider = createFileResourceProvider(ctx.remote, changes, sessions) + const provider = createFileResourceProvider(ctx.remote, changes) ctx.effect(() => { const release = ctx.resources.register(provider) // Teardown waits for every session stream still closing, so the plugin diff --git a/packages/api/workspace-files/src/client/provider.ts b/packages/api/workspace-files/src/client/provider.ts index 0010ad9d3c..7816332d2e 100644 --- a/packages/api/workspace-files/src/client/provider.ts +++ b/packages/api/workspace-files/src/client/provider.ts @@ -3,23 +3,22 @@ * `RemoteResult` frames. * * An address names the file in one of two scopes. A `session` address, - * `dsh-resource://file/session//`, carries a path relative to - * that Session's workspace root: the Host receives the relative path as-is and + * `dsh-resource://file/session//`, carries an absolute path or one + * relative to that Session's workspace root: the Host receives the path as-is and * resolves it against the root it holds. Only the Host's `stat.absolutePath` * selects the change-feed key; no Client Session summary is needed. * An `absolute` address, `dsh-resource://file/absolute/`, carries no - * Session and is read through the Session on screen. An address neither scope + * Session and cannot authorize a Host call. An address neither scope * resolves yields one failure frame — `workspace-file/unsupported-address` for * a string outside the grammar, `workspace-file/unknown-workspace` when the - * absolute address has no current Session — and ends. + * address carries no Session — and ends. * * The first frame is the file's `stat`; every Host-reported write yields the - * metadata flagged `changed`; a reported disappearance, or a write while the - * last stat had failed, runs `stat` again and flags what it finds; a reload - * runs `stat` again and clears the flag. Failures travel as `ok: false` frames, never as thrown errors: the + * metadata with its reported version; a reported disappearance, or a write while the + * last stat had failed, runs `stat` again. Failures travel as `ok: false` frames, never as thrown errors: the * Remote face does not reject, and anything thrown inside the stream is a * programming error the resource model lets surface. A failed stat does not end - * the stream: the next write or reload stats again. One {@link ChangeFeed} + * the stream: the next write stats again. One {@link ChangeFeed} * serves every open file of the Client. */ import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' @@ -30,61 +29,49 @@ import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceFileStat } from '../types.ts' import type { ChangeFeed } from './change-feed.ts' import type { WorkspaceFilesRemote } from './remote.ts' -import type { WorkspaceFileResource } from './types.ts' - -/** The current Session used to authorize an absolute address. */ -export interface SessionLookup { - /** - * The Session on screen, which an `absolute` address is read through. - * @returns its id, or `undefined` while no Session is current. - */ - current(): SessionId | undefined -} /** The Session and unmodified path submitted to the Host. */ interface HostFile { readonly sessionId: SessionId - /** The path the Host receives: workspace-relative for a `session` address, absolute for an `absolute` one. */ + /** The path the Host receives: absolute or workspace-relative. */ readonly path: string } /** - * Build the `file` provider over one Remote face, one change feed, and the Client's Session list. + * Build the `file` provider over one Remote face and one change feed. * @param remote - the Remote face carrying `workspaceFiles.stat`. * @param changes - the per-session change fan-out. - * @param sessions - the current Session, read for absolute addresses on every open and reload. * @returns the provider to register into `ctx.resources`. */ export function createFileResourceProvider( remote: WorkspaceFilesRemote, changes: ChangeFeed, - sessions: SessionLookup, ): ResourceProvider<'file'> { return { protocol: 'file', - async *open(address, { signal }): AsyncIterable> { - const resolved = resolve(address, sessions) + async *open(address, { signal }): AsyncIterable> { + const resolved = resolve(address) if (!resolved.ok) { yield resolved return } const { sessionId, path } = resolved.value // Queue changes delivered to this Client while stat is pending. - const notices = changes.follow(sessionId, address, signal) + const notices = changes.follow(sessionId, signal) const stat = (): Promise> => remote.workspaceFiles.stat(sessionId, path, signal) // Read through a call: a plain `signal.aborted` is narrowed to `false` by // the first check and would read as always-false after the later awaits. const aborted = (): boolean => signal.aborted // Undefined while the last stat failed: the follow is on the address, not - // on the file, so a write or a reload can still bring the file live. - let current: WorkspaceFileResource | undefined + // on the file, so a write can still bring the file live. + let current: WorkspaceFileStat | undefined try { if (!await notices.ready || aborted()) return const first = await stat() if (aborted()) return if (first.ok) { notices.bind(first.value.absolutePath) - current = metadataOf(first.value, false) + current = first.value yield { ok: true, value: current } } else { yield first @@ -97,11 +84,11 @@ export function createFileResourceProvider( // Frames report observations: holding this version already means the // consumer learns nothing new. if (notice.version === current.version) continue - current = { ...current, version: notice.version, changed: true } + current = { ...current, version: notice.version } yield { ok: true, value: current } continue } - // A Host notice may mean stale content; only a reload clears the flag. + // A Host notice may mean stale content. const again = await stat() if (aborted()) return if (!again.ok) { @@ -110,27 +97,22 @@ export function createFileResourceProvider( continue } notices.bind(again.value.absolutePath) - current = metadataOf(again.value, notice.kind !== 'restat') + current = again.value yield { ok: true, value: current } } } finally { notices.dispose() } }, - reload(address) { - const resolved = resolve(address, sessions) - if (resolved.ok) changes.requestRestat(resolved.value.sessionId, address) - }, } } /** * Resolve one address to the Host call it stands for, or to the failure frame it earns. * @param address - the full address, scheme included. - * @param sessions - the Client's Session list. * @returns the Host file, or the `unsupported-address` / `unknown-workspace` failure. */ -function resolve(address: string, sessions: SessionLookup): RemoteResult { +function resolve(address: string): RemoteResult { const parsed = parseFileAddress(address) if (parsed === undefined) return { ok: false, error: unsupportedAddress(address) } if (parsed.scope === 'session') { @@ -138,9 +120,7 @@ function resolve(address: string, sessions: SessionLookup): RemoteResult { return new RemoteError( 'workspace-file/unknown-workspace', - `${address} requires a current Session`, + `${address} requires a dsh-resource://file/session// address`, { address }, ) } - -/** - * The resource value one `stat` result amounts to. - * @param stat - what the Host reported. - * @param changed - whether the consumer's content may be stale: `true` after a - * Host notice prompted the stat, `false` for the opening stat and a reload's. - * @returns the metadata frame value. - */ -function metadataOf(stat: WorkspaceFileStat, changed: boolean): WorkspaceFileResource { - return { absolutePath: stat.absolutePath, version: stat.version, changed, ...(stat.bytes === undefined ? {} : { bytes: stat.bytes }) } -} diff --git a/packages/api/workspace-files/src/client/types.ts b/packages/api/workspace-files/src/client/types.ts index 606e1164b3..dcd6e74e31 100644 --- a/packages/api/workspace-files/src/client/types.ts +++ b/packages/api/workspace-files/src/client/types.ts @@ -5,15 +5,15 @@ // Bring the base `ResourceProtocolMap` declaration into this program so the // augmentation below merges into it instead of declaring a second interface. import type {} from '@deepseek-ai/dsh-client-resources/client' +import type { WorkspaceFileStat } from '../types.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface ResourceProtocolMap { /** * One workspace file's metadata, addressed as - * `dsh-resource://file/session//` or - * `dsh-resource://file/absolute/`. + * `dsh-resource://file/session//` (absolute or workspace-relative). */ - file: WorkspaceFileResource + file: WorkspaceFileStat } } @@ -27,13 +27,13 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** * The address is not a `dsh-resource://file/` address in a scope the - * provider serves: `session//` or + * provider recognizes: `session//` or * `absolute/`. Raised by the Client provider; the Host never * emits it. */ 'workspace-file/unsupported-address': { readonly address: string } /** - * An `absolute` address has no current Session to authorize its Host call. + * An `absolute` address has no Session to authorize its Host call. * Raised by the Client provider; the Host never emits it. Session addresses * are resolved by the Host without a Client Session summary. */ @@ -41,28 +41,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } } -/** - * One workspace file as the resource model carries it: metadata only. - * - * The stream reports that the file moved on; it never carries content. A - * consumer reads the text itself, by page, and uses `version` and `changed` to - * know when its pages are stale. - */ -export interface WorkspaceFileResource { - /** Absolute path in the Host filesystem, as returned by the last successful stat. */ - readonly absolutePath: string - /** The Host's latest report of the file's version: from `stat` first, then from each reported write. */ - readonly version: string - /** Byte size as of the last `stat`, when the backend reports it. */ - readonly bytes?: number - /** The Host reported a write after the last `stat`; a reload (`stat` again) clears it. */ - readonly changed: boolean -} - /** One Host-reported write inside the session's workspace. */ export type WorkspaceFileEdit = | { readonly kind: 'changed'; readonly version: string } | { readonly kind: 'absent' } -/** What one follower of a path receives: a Host write, or a local request to `stat` again. */ -export type WorkspaceFileNotice = WorkspaceFileEdit | { readonly kind: 'restat' } +/** What one follower of a path receives: a Host write. */ +export type WorkspaceFileNotice = WorkspaceFileEdit diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 42dfb13bc1..71f31a0792 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -1,20 +1,12 @@ /** - * Workspace file service: paged text reads, byte-window reads, stats, directory - * listings, and the agent-write change feed inside one session's workspace - * root, exposed as the `workspaceFiles` Remote namespace. + * Workspace file service: read-only file previews, workspace directory + * listings, and the agent-write change feed, exposed as `workspaceFiles`. * - * Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend - * fences writes and edits only, and says so. Every constraint this service - * needs is therefore its own, and there are four: - * - * 1. The path is authorized by containment in the session's workspace root. - * 2. Containment is decided by {@link FileSystem.contains}, never by comparing - * path strings: `resolve` realpaths, so a prefix test cannot see a symlink - * that leaves the root. `lstat` rejects a link before that follow happens. - * 3. Every cap is validated Config, changeable per deployment. A page is cut by - * lines and refused, not shortened, when its bytes exceed the byte cap; a - * listing is cut by entries and says so. - * 4. Failures are one `RemoteError` per reason, declared in `./types`. + * File reads follow the composed filesystem's read access, including paths + * outside the workspace. The Session's policy supplies the base for relative + * paths, not a read-containment restriction. Directory listings and change + * observations remain workspace-scoped. File-kind checks and configured read + * caps apply to every preview; this service exposes no mutations. * * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it * goes, so the file is read only up to the first character past the page and @@ -25,6 +17,7 @@ * file content across the wire, which is a different level of exposure. */ +import { posix, win32 } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -63,6 +56,8 @@ export interface Config { * way. The file itself has no size cap: a caller pages through it. */ readonly maxBytes: number + /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */ + readonly maxFileBytes: number /** Default and largest page size in lines; a request asking for more is refused. */ readonly maxLines: number /** Cap on returned directory entries; the rest is dropped and reported cut. */ @@ -164,12 +159,13 @@ function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry { } } -/** Host Remote service over the composed filesystem, confined to one workspace. */ +/** Host Remote file reads and workspace directory observations over the composed filesystem. */ export class WorkspaceFiles extends TypertRemoteService { static inject = ['fs', 'sandboxPolicy', 'typert'] static Config: z = z.object({ maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024), + maxFileBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER - 1).default(32 * 1024 * 1024), maxLines: z.number().step(1).min(1).default(5000), maxEntries: z.number().step(1).min(1).default(2000), }) @@ -186,9 +182,9 @@ export class WorkspaceFiles extends TypertRemoteService { } /** - * Read one page of lines from a UTF-8 text file inside the Agent's workspace. + * Read one page of lines from a UTF-8 file readable by the filesystem backend. * @param agent - target Agent resolved from the Session identity on the wire. - * @param path - workspace path, absolute or relative to the workspace root. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the line window; omitted fields take the page defaults. * @param signal - caller cancellation. * @returns the page, the file's version at the stat before it, and whether it reaches the last line. @@ -205,10 +201,10 @@ export class WorkspaceFiles extends TypertRemoteService { } /** - * Read one byte window of a regular file inside the Agent's workspace: raw + * Read one byte window of a regular file readable by the filesystem backend: raw * bytes, no text decoding and no binary rejection. * @param agent - target Agent resolved from the Session identity on the wire. - * @param path - workspace path, absolute or relative to the workspace root. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param range - the byte window; omitted fields take the window defaults. * @param signal - caller cancellation. * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. @@ -222,10 +218,51 @@ export class WorkspaceFiles extends TypertRemoteService { return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof } } + /** + * Read a complete regular file as bytes, subject to the configured full-file cap. + * @param agent - target Agent whose workspace resolves relative paths. + * @param path - absolute or workspace-relative file path. + * @param signal - caller cancellation. + * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. + */ + @Remote + async readAll(agent: Agent, path: string, signal: AbortSignal): Promise { + const { target, info } = await this.locateFile(agent, path, signal) + const limit = this.config.maxFileBytes + if (info.size !== undefined && info.size > limit) { + throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) + } + const data = await this.ctx.fs.readByteRange(target, { offset: 0, length: limit + 1 }, signal) + if (data.length > limit) { + throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) + } + return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true } + } + + /** + * Read a complete file relative to another file's directory, including outside the workspace. + * @param agent - Agent whose workspace resolves the base file's relative path. + * @param path - base file, absolute or workspace-relative. + * @param relativePath - relative filesystem path, not a URL or absolute path. + * @param signal - caller cancellation. + * @returns the complete related file using the ordinary file-size and access checks. + */ + @Remote + async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise { + const relative = relativePath.replace(/\\/g, '/') + if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) { + throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {}) + } + const { target } = await this.locateFile(agent, path, signal) + const absolute = this.ctx.fs.processPath(target) + const paths = absolute.startsWith('/') ? posix : win32 + return this.readAll(agent, paths.resolve(paths.dirname(absolute), relative), signal) + } + /** * Report one regular file's identity, version, and size without its content. * @param agent - target Agent resolved from the Session identity on the wire. - * @param path - workspace path, absolute or relative to the workspace root. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. * @param signal - caller cancellation. * @returns the file's absolute path, current version, and byte size. */ @@ -314,12 +351,8 @@ export class WorkspaceFiles extends TypertRemoteService { } /** - * Gates 1 and 2 up to the point where the path's own type is known. The - * path is inspected before containment is decided, so a caller learns whether - * an outside path exists and what kind it is before `outside-workspace` - * refuses it; the caller is the Session's own owner, who can read the Host - * through the Agent anyway, and the accepted cost buys one `lstat` gate for - * every method instead of two resolution orders. + * Inspect the requested path itself before resolution follows its final + * component. Directory containment is checked separately by `list`. */ private async inspect( agent: Agent, @@ -352,11 +385,11 @@ export class WorkspaceFiles extends TypertRemoteService { * changed kind in between. */ private async locateFile(agent: Agent, path: string, signal: AbortSignal): Promise<{ target: FsTarget; info: FsInfo }> { - const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal) + const { workspaceRoot, entry } = await this.inspect(agent, path, signal) if (entry.type !== 'file') { throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type }) } - const target = await this.confine(root, workspaceRoot, path, signal) + const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal }) const info = await this.ctx.fs.stat(target, signal) if (info === undefined) { throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path }) diff --git a/packages/api/workspace-files/src/types.ts b/packages/api/workspace-files/src/types.ts index 83aad4841f..697c9bd1f8 100644 --- a/packages/api/workspace-files/src/types.ts +++ b/packages/api/workspace-files/src/types.ts @@ -146,7 +146,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** No entry exists at that path inside the workspace. */ 'workspace-file/not-found': { readonly path: string } - /** The path resolves outside the session's workspace root. */ + /** The directory listing path resolves outside the session's workspace root. */ 'workspace-file/outside-workspace': { readonly path: string } /** The requested page exceeds the configured byte cap; nothing is returned. */ 'workspace-file/too-large': { readonly path: string; readonly limit: number } diff --git a/packages/api/workspace-files/tests/apply.client.spec.ts b/packages/api/workspace-files/tests/apply.client.spec.ts index 5153814358..00001f9fea 100644 --- a/packages/api/workspace-files/tests/apply.client.spec.ts +++ b/packages/api/workspace-files/tests/apply.client.spec.ts @@ -1,27 +1,22 @@ /** * The plugin body: one `file` provider registered into `ctx.resources` for the - * fiber's lifetime, reading through `ctx.remote` and resolving addresses - * authorizing absolute addresses through the current `ctx.sessions` entry. + * fiber's lifetime, reading through `ctx.remote` in the Session each address names. */ import { Context } from '@deepseek-ai/cordis' import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' -import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { describe, expect, it, onTestFinished } from 'vitest' import { apply, inject } from '../src/client/index.ts' import { FakeRemote, settle } from './fake-remote.client.ts' describe('workspace-files client apply', () => { - it('registers the file provider over ctx.remote and ctx.sessions, and unregisters it with the fiber', async () => { + it('registers the file provider over ctx.remote, and unregisters it with the fiber', async () => { const ctx = new Context() const remote = new FakeRemote() const controller = new AbortController() const pulls: Array> = [] ctx.provide('remote', remote as never) ctx.provide('remote.workspaceFiles', remote.workspaceFiles as never) - // No Session summary or cwd is available; only absolute addresses need current. - ctx.provide('sessions', { - list: { getSnapshot: () => ({ byId: {}, current: 's1' }) }, - } as never) const registered: Array> = [] let released = 0 ctx.provide('resources', { @@ -45,10 +40,9 @@ describe('workspace-files client apply', () => { expect(registered.map(provider => provider.protocol)).toEqual(['file']) const signal = controller.signal - // A session address reaches the Host as its relative path; an absolute one - // is read through the current Session with its absolute path. + // A session address reaches the Host with its relative or absolute path. pulls.push(registered[0]!.open(sessionFileAddress('s1', 'a.txt'), { signal })[Symbol.asyncIterator]().next()) - pulls.push(registered[0]!.open(absoluteFileAddress('/etc/hosts'), { signal })[Symbol.asyncIterator]().next()) + pulls.push(registered[0]!.open(sessionFileAddress('s1', '/etc/hosts'), { signal })[Symbol.asyncIterator]().next()) await settle() expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([['s1', 'a.txt'], ['s1', '/etc/hosts']]) expect(remote.opened).toHaveLength(1) diff --git a/packages/api/workspace-files/tests/change-feed.client.spec.ts b/packages/api/workspace-files/tests/change-feed.client.spec.ts index 9fe3ed4c73..3bae81a440 100644 --- a/packages/api/workspace-files/tests/change-feed.client.spec.ts +++ b/packages/api/workspace-files/tests/change-feed.client.spec.ts @@ -1,6 +1,6 @@ /** * The change feed's promises: one Host stream per session, delivery by absolute - * path, local stat requests, and a follower's life bounded by its signal or by + * path, and a follower's life bounded by its signal or by * the stream's end. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -16,7 +16,7 @@ function harness() { const remote = new FakeRemote() const feed = new ChangeFeed(remote) const follow = (sessionId: SessionId, path: string, controller = new AbortController()) => { - const follower = feed.follow(sessionId, path, controller.signal) + const follower = feed.follow(sessionId, controller.signal) follower.bind(path) return { it: follower[Symbol.asyncIterator](), controller } } @@ -27,10 +27,10 @@ describe('ChangeFeed — one Host stream per session', () => { it('starts a later follower from the existing session acknowledgement without opening another stream', async () => { const { remote, feed } = harness() const controller = new AbortController() - const first = feed.follow(S1, 'first-resource', controller.signal) + const first = feed.follow(S1, controller.signal) try { await expect(first.ready).resolves.toBe(true) - const second = feed.follow(S1, 'second-resource', controller.signal) + const second = feed.follow(S1, controller.signal) await expect(second.ready).resolves.toBe(true) expect(remote.calls).toEqual(['changes', 'accept']) expect(remote.opened).toHaveLength(1) @@ -189,26 +189,14 @@ describe('ChangeFeed — delivery', () => { await expect(two.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } }) await expect(peek(one.it)).resolves.toBe('silent') }) - - it('hands a stat request to the followers of that path only', async () => { - const { feed, follow } = harness() - const mine = follow(S1, '/w/a.txt') - const other = follow(S1, '/w/b.txt') - await settle() - feed.requestRestat(S1, '/w/a.txt') - feed.requestRestat(S2, '/w/a.txt') - feed.requestRestat(S1, '/w/nobody.txt') - await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'restat' } }) - await expect(peek(other.it)).resolves.toBe('silent') - }) }) describe('ChangeFeed — a follower ends', () => { it('ends every follower and disposes the session stream for an unknown wire frame kind', async () => { const { remote, feed } = harness() const controller = new AbortController() - const first = feed.follow(S1, 'first-resource', controller.signal) - const second = feed.follow(S1, 'second-resource', controller.signal) + const first = feed.follow(S1, controller.signal) + const second = feed.follow(S1, controller.signal) const firstIterator = first[Symbol.asyncIterator]() const secondIterator = second[Symbol.asyncIterator]() try { @@ -248,7 +236,7 @@ describe('ChangeFeed — a follower ends', () => { const { remote, feed } = harness() const controller = new AbortController() controller.abort() - const it = feed.follow(S1, '/w/a.txt', controller.signal)[Symbol.asyncIterator]() + const it = feed.follow(S1, controller.signal)[Symbol.asyncIterator]() await expect(it.next()).resolves.toEqual({ done: true, value: undefined }) await settle() expect(remote.opened).toEqual([]) diff --git a/packages/api/workspace-files/tests/changes.spec.ts b/packages/api/workspace-files/tests/changes.spec.ts index 452dbaabee..b21b92e8db 100644 --- a/packages/api/workspace-files/tests/changes.spec.ts +++ b/packages/api/workspace-files/tests/changes.spec.ts @@ -149,7 +149,7 @@ describe('workspaceFiles.changes — ending', () => { }) let service: WorkspaceFiles | undefined const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => { - service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 }) + service = new WorkspaceFiles(ctx, { maxBytes: 1, maxFileBytes: 1, maxLines: 1, maxEntries: 1 }) }, { inject: ['fs', 'sandboxPolicy'] })) try { if (service === undefined) throw new Error('plugin body did not run') @@ -267,7 +267,7 @@ describe('workspaceFiles.changes — ending', () => { it('ends every open generation when the owning fiber is disposed', async () => { let service: WorkspaceFiles | undefined const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => { - service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 }) + service = new WorkspaceFiles(ctx, { maxBytes: 1, maxFileBytes: 1, maxLines: 1, maxEntries: 1 }) }, { inject: ['fs', 'sandboxPolicy'] })) if (service === undefined) throw new Error('plugin body did not run') const stream = open(service) diff --git a/packages/api/workspace-files/tests/harness.ts b/packages/api/workspace-files/tests/harness.ts index 2b1a5ece77..17872a7875 100644 --- a/packages/api/workspace-files/tests/harness.ts +++ b/packages/api/workspace-files/tests/harness.ts @@ -64,6 +64,7 @@ export async function openWorkspace(prefix: string): Promise { } service = new WorkspaceFiles(ctx, { maxBytes: caps?.maxBytes ?? 1024 * 1024, + maxFileBytes: caps?.maxFileBytes ?? 1024 * 1024, maxLines: caps?.maxLines ?? 5000, maxEntries: caps?.maxEntries ?? 2000, }) diff --git a/packages/api/workspace-files/tests/host-path.client.spec.ts b/packages/api/workspace-files/tests/host-path.client.spec.ts index 306222f6d7..c1a4328bf4 100644 --- a/packages/api/workspace-files/tests/host-path.client.spec.ts +++ b/packages/api/workspace-files/tests/host-path.client.spec.ts @@ -1,10 +1,10 @@ -/** Host-resolved file identities across pending stats, retries, reloads, and disposal. */ +/** Host-resolved file identities across pending stats, retries, and disposal. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { describe, expect, it, onTestFinished } from 'vitest' import { ChangeFeed } from '../src/client/change-feed.ts' -import { createFileResourceProvider, type SessionLookup } from '../src/client/provider.ts' +import { createFileResourceProvider } from '../src/client/provider.ts' import { FakeRemote, settle } from './fake-remote.client.ts' const SESSION = 'host-only' as SessionId @@ -12,10 +12,10 @@ const RELATIVE = 'linked/a b.txt' const ADDRESS = sessionFileAddress(SESSION, RELATIVE) const CANONICAL = '/host/canonical/a b.txt' -function harness(sessions: SessionLookup = { current: () => SESSION }) { +function harness() { const remote = new FakeRemote() const changes = new ChangeFeed(remote) - const provider = createFileResourceProvider(remote, changes, sessions) + const provider = createFileResourceProvider(remote, changes) const open = (address = ADDRESS) => { const controller = new AbortController() const iterator = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]() @@ -27,21 +27,21 @@ function harness(sessions: SessionLookup = { current: () => SESSION }) { await iterator.return?.() await changes.settle() }) - return { iterator, controller, reload: () => { provider.reload!(address) } } + return { iterator, controller } } return { remote, changes, open } } describe('Host-resolved file paths', () => { it('accepts Host ready before submitting the unmodified relative path without a Client Session summary', async () => { - const { remote, open } = harness({ current: () => { throw new Error('session addresses do not read current') } }) + const { remote, open } = harness() const { iterator } = open() const first = iterator.next() const request = await remote.waitForStat(0) expect(remote.calls).toEqual(['changes', 'accept', 'stat']) expect(request).toMatchObject({ sessionId: SESSION, path: RELATIVE }) request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } }) - await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3, changed: false } } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } } }) }) it('does not stat an opened changes stream until the Host acknowledges its subscription', async () => { @@ -58,7 +58,7 @@ describe('Host-resolved file paths', () => { const request = await remote.waitForStat(0) expect(remote.calls).toEqual(['changes', 'accept', 'stat']) request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } }) - await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', changed: false } } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } } }) }) it.each(['abort', 'end', 'failure'] as const)('settles %s before Host ready without sending a stat or leaving a stream', async (ending) => { @@ -94,12 +94,12 @@ describe('Host-resolved file paths', () => { await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } }) expect(remote.stats).toHaveLength(1) expect(remote.calls).toEqual(['changes', 'accept', 'stat', 'accept']) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } }) + await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } }) }) it.each([ - ['session', ADDRESS, RELATIVE], - ['absolute', absoluteFileAddress('/shortcut/a.txt'), '/shortcut/a.txt'], + ['relative', ADDRESS, RELATIVE], + ['absolute', sessionFileAddress(SESSION, '/shortcut/a.txt'), '/shortcut/a.txt'], ])('filters queued and live %s changes using the Host canonical path, not the input path', async (_, address, path) => { const { remote, open } = harness() const { iterator } = open(address) @@ -111,18 +111,18 @@ describe('Host-resolved file paths', () => { await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-before-stat' } }) await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } }) request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } }) - await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } }) - await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } }) + await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } }) await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-after-stat' } }) await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } }) - await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } }) + await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } }) expect(remote.stats).toHaveLength(1) }) - it.each(['reload', 'write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async (trigger) => { + it.each(['write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async () => { const { remote, open } = harness() - const { iterator, reload } = open() + const { iterator } = open() const first = iterator.next() const request = await remote.waitForStat(0) const error = new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE }) @@ -130,66 +130,35 @@ describe('Host-resolved file paths', () => { await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } }) const source = remote.opened[0]!.source const retried = iterator.next() - if (trigger === 'reload') reload() - else await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } }) + await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } }) const retry = await remote.waitForStat(1) expect(retry).toMatchObject({ sessionId: SESSION, path: RELATIVE }) await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other' } }) await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v3' } }) retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } }) await expect(retried).resolves.toEqual({ - done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5, changed: trigger === 'write' } }, + done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } }, }) - await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3', changed: true } } }) + await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3' } } }) expect(remote.stats).toHaveLength(2) }) - it('reloads session and absolute addresses bound to the same Host key without re-statting other files', async () => { + it('binds a changed canonical path after disappearance before filtering writes received during that stat', async () => { const { remote, open } = harness() - const session = open() - const absolute = open(absoluteFileAddress('/shortcut/a.txt')) - const other = open(sessionFileAddress(SESSION, 'other.txt')) - const firsts = Promise.all([session.iterator.next(), absolute.iterator.next(), other.iterator.next()]) - await remote.waitForStat(2) - for (const request of remote.stats) { - request.resolve({ ok: true, value: { - absolutePath: request.path === 'other.txt' ? '/host/other.txt' : CANONICAL, - version: 'v0', bytes: 3, - } }) - } - await firsts - const reloaded = Promise.all([session.iterator.next(), absolute.iterator.next()]) - const otherChange = other.iterator.next() - session.reload() - await remote.waitForStat(4) - expect(remote.stats.slice(3).map(request => request.path)).toEqual([RELATIVE, '/shortcut/a.txt']) - for (const request of remote.stats.slice(3)) { - request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 4 } }) - } - const value = { done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 4, changed: false } } } - await expect(reloaded).resolves.toEqual([value, value]) - await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/other.txt', version: 'other-v1' } }) - await expect(otherChange).resolves.toMatchObject({ value: { ok: true, value: { version: 'other-v1', changed: true } } }) - expect(remote.stats).toHaveLength(5) - expect(remote.opened).toHaveLength(1) - }) - - it('binds a changed canonical path on reload before filtering writes received during that stat', async () => { - const { remote, open } = harness() - const { iterator, reload } = open() + const { iterator } = open() const first = iterator.next() const request = await remote.waitForStat(0) request.resolve({ ok: true, value: { absolutePath: '/host/old-target.txt', version: 'v0' } }) await first - reload() const reloaded = iterator.next() + await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', absent: true } }) const retry = await remote.waitForStat(1) const source = remote.opened[0]!.source await source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', version: 'old-target-write' } }) await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } }) retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1' } }) - await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: false } } }) - await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } }) + await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) + await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } }) expect(remote.stats).toHaveLength(2) }) diff --git a/packages/api/workspace-files/tests/provider.client.spec.ts b/packages/api/workspace-files/tests/provider.client.spec.ts index 539a3888af..5a7550db60 100644 --- a/packages/api/workspace-files/tests/provider.client.spec.ts +++ b/packages/api/workspace-files/tests/provider.client.spec.ts @@ -1,7 +1,7 @@ /** * The `file` provider's frame stream: how the two address scopes resolve to a - * Host call and a change-feed key, the opening stat, the write flag that - * carries no content, the reload and disappearance that stat again, failures + * Host call and a change-feed key, the opening stat, the write version that + * carries no content, the disappearance that stats again, failures * as frames, and the life bounded by the signal. */ import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -12,8 +12,7 @@ import type { WorkspaceFileStat } from '../src/types.ts' import { describe, expect, it, onTestFinished } from 'vitest' import { ChangeFeed } from '../src/client/change-feed.ts' import { createFileResourceProvider } from '../src/client/provider.ts' -import type { SessionLookup } from '../src/client/provider.ts' -import { FakeRemote, peek, settle } from './fake-remote.client.ts' +import { FakeRemote, settle } from './fake-remote.client.ts' const S1 = 's1' as SessionId const S2 = 's2' as SessionId @@ -23,23 +22,17 @@ const HOST_PATH = '/w/a b.txt' const ADDRESS = sessionFileAddress(S1, REL_PATH) /** A file outside every workspace root, addressed absolutely. */ const ABS_PATH = '/etc/hosts' -const ABS_ADDRESS = absoluteFileAddress(ABS_PATH) - -/** Only the current Session is available to the provider; there are no Client roots. */ -function sessionsWith(current: SessionId | undefined): SessionLookup { - return { current: () => current } -} +const ABS_ADDRESS = sessionFileAddress(S1, ABS_PATH) const stat = (version: string, bytes: number): WorkspaceFileStat => ({ absolutePath: HOST_PATH, version, bytes }) const notFound = (): RemoteFailure => new RemoteError('workspace-file/not-found', 'no such file', { path: REL_PATH }) -function opened(address = ADDRESS, sessions = sessionsWith(S1)) { +function opened(address = ADDRESS) { const remote = new FakeRemote() const changes = new ChangeFeed(remote) - const provider = createFileResourceProvider(remote, changes, sessions) + const provider = createFileResourceProvider(remote, changes) const controller = new AbortController() const it = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]() - const reload = (): void => { provider.reload!(address) } onTestFinished(async () => { controller.abort() for (const request of remote.stats) { @@ -48,7 +41,7 @@ function opened(address = ADDRESS, sessions = sessionsWith(S1)) { await it.return?.() await changes.settle() }) - return { remote, provider, changes, controller, it, reload } + return { remote, provider, changes, controller, it } } /** Open, answer the opening stat, and hand back the bench once the first frame is out. */ @@ -80,9 +73,9 @@ describe('file provider — the address', () => { expect(remote.opened).toEqual([]) }) - it('rejects an absolute address with no current Session without touching the Remote', async () => { - const address = ABS_ADDRESS - const { remote, it } = opened(address, sessionsWith(undefined)) + it('rejects an absolute address with no Session without touching the Remote', async () => { + const address = absoluteFileAddress(ABS_PATH) + const { remote, it } = opened(address) const first = await it.next() expect(first.done).toBe(false) expect(first.value).toMatchObject({ ok: false, error: { code: 'workspace-file/unknown-workspace', details: { address } } }) @@ -91,14 +84,6 @@ describe('file provider — the address', () => { expect(remote.opened).toEqual([]) }) - it('reloads nothing for an address it does not resolve', async () => { - for (const bench of [opened('dsh-resource://file/shared/x/w/a.txt'), opened(ABS_ADDRESS, sessionsWith(undefined))]) { - bench.reload() - await settle() - expect(bench.remote.stats).toEqual([]) - } - }) - it('hands the Host a session address\'s relative path and follows the stat absolute path', async () => { const { remote, it } = opened() const first = it.next() @@ -108,31 +93,24 @@ describe('file provider — the address', () => { await first // The Host's frame names the file absolutely; the follower keyed by the resolved path receives it. remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) }) - it('reads an absolute address through the Session on screen, with the absolute path as both Host path and follow key', async () => { - const { remote, it, reload } = opened(ABS_ADDRESS) + it('reads an absolute path through the Session in its address, with the absolute path as both Host path and follow key', async () => { + const { remote, it } = opened(ABS_ADDRESS) const first = it.next() await settle() expect(remote.opened.map(o => o.sessionId)).toEqual([S1]) expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: ABS_PATH }) remote.stats[0]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } }) - await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3, changed: false } } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } } }) remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: ABS_PATH, version: 'v1' } }) - await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) - // A reload stats the same absolute path again. - reload() - const next = it.next() - await settle() - expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: ABS_PATH }) - remote.stats[1]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 4 } }) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 4, changed: false } } }) + await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) }) it('opens one change stream per session named by the addresses', async () => { const remote = new FakeRemote() - const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1)) + const provider = createFileResourceProvider(remote, new ChangeFeed(remote)) const signal = new AbortController().signal void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next() void provider.open(sessionFileAddress(S2, 'a.txt'), { signal })[Symbol.asyncIterator]().next() @@ -143,14 +121,14 @@ describe('file provider — the address', () => { }) describe('file provider — the opening stat', () => { - it('stats the decoded relative path in the session the address names and yields its metadata unflagged', async () => { + it('stats the decoded relative path in the session the address names and yields its metadata', async () => { const { remote, it, controller } = opened() const first = it.next() await settle() expect(remote.stats).toHaveLength(1) expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH, signal: controller.signal }) remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) - await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3 } } }) }) it('omits bytes when the backend reports none', async () => { @@ -158,7 +136,7 @@ describe('file provider — the opening stat', () => { const first = it.next() await settle() remote.stats[0]!.resolve({ ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } }) - await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', changed: false } } }) + await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } } }) }) it('yields the Host failure as a frame and keeps following the address', async () => { @@ -174,24 +152,11 @@ describe('file provider — the opening stat', () => { const pending = it.next() await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent') expect(remote.stats).toHaveLength(1) - // The agent creates the file: the write stats again and the value goes live, flagged as changed. + // The agent creates the file: the write stats again and the value goes live. source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) await settle() remote.stats[1]!.resolve({ ok: true, value: stat('v1', 5) }) - await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 5, changed: true } } }) - }) - - it('lets a reload retry a failed opening stat', async () => { - const { remote, it, reload } = opened() - const first = it.next() - await settle() - remote.stats[0]!.resolve({ ok: false, error: notFound() }) - await first - reload() - const next = it.next() - await settle() - remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) }) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } }) + await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 5 } } }) }) it('ends without a frame when aborted during the stat', async () => { @@ -205,7 +170,7 @@ describe('file provider — the opening stat', () => { it('shares one change stream between two files of a session', async () => { const remote = new FakeRemote() - const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1)) + const provider = createFileResourceProvider(remote, new ChangeFeed(remote)) const signal = new AbortController().signal void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next() void provider.open(sessionFileAddress(S1, 'b.txt'), { signal })[Symbol.asyncIterator]().next() @@ -216,10 +181,10 @@ describe('file provider — the opening stat', () => { }) describe('file provider — Host writes', () => { - it('flags a write with its version and keeps the byte count', async () => { + it('reports a write with its version and keeps the byte count', async () => { const { remote, it } = await live() remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3, changed: true } } }) + await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3 } } }) expect(remote.stats).toHaveLength(1) }) @@ -231,7 +196,7 @@ describe('file provider — Host writes', () => { const pending = it.next() await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent') source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) }) it('does not lose a write reported during the opening stat', async () => { @@ -241,23 +206,35 @@ describe('file provider — Host writes', () => { remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) await settle() remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) - await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } }) - await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } }) + await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) }) }) describe('file provider — a reported disappearance', () => { - it('stats again and, when the file is still there, yields its fresh metadata flagged', async () => { + it('ends quietly when aborted during the stat', async () => { + const { remote, it, controller, changes } = await live() + const next = it.next() + await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } }) + const request = await remote.waitForStat(1) + controller.abort() + request.resolve({ ok: true, value: stat('late', 9) }) + await expect(next).resolves.toEqual({ done: true, value: undefined }) + await changes.settle() + expect(remote.disposed).toEqual(['workspace file changes of s1']) + }) + + it('stats again and, when the file is still there, yields its fresh metadata', async () => { const { remote, it } = await live('v0', 3) remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } }) const next = it.next() await settle() expect(remote.stats).toHaveLength(2) remote.stats[1]!.resolve({ ok: true, value: stat('v2', 9) }) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v2', bytes: 9, changed: true } } }) + await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v2', bytes: 9 } } }) }) - it('yields the not-found frame and keeps following, so a later write stats again and brings the file back flagged', async () => { + it('yields the not-found frame and keeps following, so a later write stats again and brings the file back', async () => { const { remote, it } = await live('v0', 3) const source = remote.opened[0]!.source source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } }) @@ -270,79 +247,7 @@ describe('file provider — a reported disappearance', () => { const back = it.next() await settle() remote.stats[2]!.resolve({ ok: true, value: stat('v3', 8) }) - await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v3', bytes: 8, changed: true } } }) - }) -}) - -describe('file provider — reload', () => { - it('stats again and clears the flag', async () => { - const { remote, it, reload } = await live('v0', 3) - remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await it.next() - reload() - const next = it.next() - await settle() - expect(remote.stats).toHaveLength(2) - expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: REL_PATH }) - remote.stats[1]!.resolve({ ok: true, value: stat('v1', 7) }) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 7, changed: false } } }) - }) - - it('yields a failed stat as a frame and keeps the stream open', async () => { - const { remote, it, reload } = await live() - reload() - const next = it.next() - await settle() - const error = notFound() - remote.stats[1]!.resolve({ ok: false, error }) - await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } }) - await expect(peek(it)).resolves.toBe('silent') - }) - - it('ends quietly when aborted during the stat', async () => { - const { remote, it, reload, controller } = await live() - reload() - const next = it.next() - await settle() - controller.abort() - remote.stats[1]!.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) }) - await expect(next).resolves.toEqual({ done: true, value: undefined }) - }) - - it('re-stats every record of the path on one record\'s reload: a session record and an absolute record of one file share it', async () => { - const remote = new FakeRemote() - const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1)) - const signal = new AbortController().signal - const session = provider.open(ADDRESS, { signal })[Symbol.asyncIterator]() - const absolute = provider.open(absoluteFileAddress(HOST_PATH), { signal })[Symbol.asyncIterator]() - const firsts = Promise.all([session.next(), absolute.next()]) - await settle() - expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([[S1, REL_PATH], [S1, HOST_PATH]]) - remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) - remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) }) - await firsts - expect(remote.opened).toHaveLength(1) - // One Host write flags both records: they follow one path. - remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await expect(session.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) - await expect(absolute.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) - // A reload on the session record re-stats both and clears both flags: - // delivery is per path, not per record. - provider.reload!(ADDRESS) - const nexts = Promise.all([session.next(), absolute.next()]) - await settle() - expect(remote.stats.slice(2).map(pending => pending.path)).toEqual([REL_PATH, HOST_PATH]) - remote.stats[2]!.resolve({ ok: true, value: stat('v1', 3) }) - remote.stats[3]!.resolve({ ok: true, value: stat('v1', 3) }) - const cleared = { done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3, changed: false } } } - await expect(nexts).resolves.toEqual([cleared, cleared]) - }) - - it('is a no-op for a file nobody has open', async () => { - const { remote, reload } = opened() - reload() - await settle() - expect(remote.stats).toHaveLength(0) + await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v3', bytes: 8 } } }) }) }) diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts new file mode 100644 index 0000000000..6458c341cd --- /dev/null +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -0,0 +1,113 @@ +/** Full-file reads retain the ordinary file gates and never return a silently truncated result. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { FsVersion } from '@deepseek-ai/dsh-fs' +import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' + +let harness: Harness + +beforeEach(async () => { harness = await openWorkspace('dsh-workspace-files-read-all-') }) +afterEach(async () => { await harness.dispose() }) + +describe('workspaceFiles.readAll', () => { + it('reads the complete bytes independently of the window cap', async () => { + const bytes = Buffer.from([0, 255, 1, 2]) + await writeFile(join(harness.workspace, 'file.bin'), bytes) + const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(agent, 'file.bin', signal()) + expect(Buffer.from(result.data, 'base64')).toEqual(bytes) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 4 }) + }) + + it('returns an empty complete file', async () => { + await writeFile(join(harness.workspace, 'empty'), '') + expect(await harness.endpoint().readAll(agent, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + }) + + it.each(['workspace', 'outside'] as const)('rejects a known oversized %s file before reading bytes', async (location) => { + const path = join(harness[location], 'large') + await writeFile(path, 'abcde') + const read = vi.spyOn(harness.ctx.fs, 'readByteRange') + expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, path, signal()))) + .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } }) + expect(read).not.toHaveBeenCalled() + }) + + it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => { + await writeFile(join(harness.workspace, 'growing'), 'abcde') + vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v'), ...size === undefined ? {} : { size } }) + expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, 'growing', signal()))).code) + .toBe('workspace-file/too-large') + }) + + it('reads outside files while retaining missing-file and kind failures', async () => { + await mkdir(join(harness.workspace, 'directory')) + await writeFile(join(harness.outside, 'outside'), 'outside') + const files = harness.endpoint() + expect((await failureOf(files.readAll(agent, 'missing', signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(files.readAll(agent, 'directory', signal()))).code).toBe('workspace-file/not-regular-file') + const outside = await files.readAll(agent, join(harness.outside, 'outside'), signal()) + expect(Buffer.from(outside.data, 'base64').toString()).toBe('outside') + }) +}) + +describe('workspaceFiles.readRelated', () => { + it('resolves relative paths from the base file directory on the Host', async () => { + await mkdir(join(harness.workspace, 'nested')) + await writeFile(join(harness.workspace, 'nested/base.txt'), 'base') + await writeFile(join(harness.workspace, 'nested/near.txt'), 'near') + await writeFile(join(harness.workspace, 'root.txt'), 'root') + const files = harness.endpoint() + const near = await files.readRelated(agent, 'nested/base.txt', './near.txt', signal()) + const root = await files.readRelated(agent, 'nested/base.txt', '../root.txt', signal()) + expect(Buffer.from(near.data, 'base64').toString()).toBe('near') + expect(Buffer.from(root.data, 'base64').toString()).toBe('root') + const fromRoot = await files.readRelated(agent, 'root.txt', 'nested\\near.txt', signal()) + expect(Buffer.from(fromRoot.data, 'base64').toString()).toBe('near') + }) + + it.each(['', '/outside', 'C:\\outside', '\\\\host\\share', 'https://example.test/a.js', 'bad\0path'])('rejects non-relative path %j', async (path) => { + expect((await failureOf(harness.endpoint().readRelated(agent, 'base', path, signal()))).code).toBe('gateway/bad-request') + }) + + it('resolves related files on either side of the workspace root', async () => { + await writeFile(join(harness.workspace, 'base'), 'base') + await writeFile(join(harness.outside, 'outside'), 'outside') + const files = harness.endpoint() + expect((await failureOf(files.readRelated(agent, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found') + const fromOutside = await files.readRelated(agent, join(harness.outside, 'outside'), '../workspace/base', signal()) + const toOutside = await files.readRelated(agent, 'base', '../outside/outside', signal()) + expect(Buffer.from(fromOutside.data, 'base64').toString()).toBe('base') + expect(Buffer.from(toOutside.data, 'base64').toString()).toBe('outside') + }) + + it('reads sibling assets beside an outside HTML file with escaped path characters', async () => { + await mkdir(join(harness.outside, 'space # assets')) + const base = join(harness.outside, 'space # assets', 'page.html') + await writeFile(base, '') + await writeFile(join(harness.outside, 'space # assets', 'app.js'), 'EXTERNAL_ASSET') + const result = await harness.endpoint().readRelated(agent, base, './app.js', signal()) + expect(Buffer.from(result.data, 'base64').toString()).toBe('EXTERNAL_ASSET') + }) + + it.each([ + ['C:\\external\\page.html', 'C:\\external\\app.js'], + ['\\\\server\\share\\page.html', '\\\\server\\share\\app.js'], + ['/external/back\\slash.html', '/external/app.js'], + ])('uses the backend process-path syntax for related reads from %s', async (base, expected) => { + await writeFile(join(harness.workspace, 'base'), 'base') + const files = harness.endpoint() + vi.spyOn(harness.ctx.fs, 'processPath').mockReturnValue(base) + const read = vi.spyOn(files, 'readAll').mockResolvedValue({ absolutePath: expected, version: 'v', offset: 0, data: '', eof: true }) + const caller = signal() + await files.readRelated(agent, 'base', './app.js', caller) + expect(read).toHaveBeenCalledWith(agent, expected, caller) + }) + + it('rejects a related symlink rather than following it', async () => { + await writeFile(join(harness.workspace, 'base'), 'base') + await writeFile(join(harness.workspace, 'target'), 'target') + await symlink('target', join(harness.workspace, 'link')) + expect((await failureOf(harness.endpoint().readRelated(agent, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file') + }) +}) diff --git a/packages/api/workspace-files/tests/read-bytes.spec.ts b/packages/api/workspace-files/tests/read-bytes.spec.ts index 8785349795..73f64f71e4 100644 --- a/packages/api/workspace-files/tests/read-bytes.spec.ts +++ b/packages/api/workspace-files/tests/read-bytes.spec.ts @@ -138,9 +138,10 @@ describe('workspaceFiles.readBytes — the gates it shares with read', () => { expect((await failureOf(endpoint().readBytes(agent, '', {}, signal()))).code).toBe('gateway/bad-request') }) - it('rejects an absolute path outside the workspace', async () => { - await writeFile(join(harness.outside, 'secret.bin'), RAMP) - const failure = await failureOf(endpoint().readBytes(agent, join(harness.outside, 'secret.bin'), {}, signal())) - expect(failure.code).toBe('workspace-file/outside-workspace') + it('reads a bounded byte window outside the workspace', async () => { + await writeFile(join(harness.outside, 'sample.bin'), RAMP) + const result = await endpoint().readBytes(agent, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal()) + expect(decode(result.data)).toEqual(RAMP.subarray(2, 6)) + expect(result).toMatchObject({ offset: 2, eof: false }) }) }) diff --git a/packages/api/workspace-files/tests/read.spec.ts b/packages/api/workspace-files/tests/read.spec.ts index 2ee3fa95b3..82fced2461 100644 --- a/packages/api/workspace-files/tests/read.spec.ts +++ b/packages/api/workspace-files/tests/read.spec.ts @@ -125,17 +125,27 @@ describe('workspaceFiles.read — the line window', () => { }) }) -describe('workspaceFiles.read — gate 1 and 2: authorization by containment', () => { - it('rejects an absolute path outside the workspace', async () => { - await writeFile(join(outside, 'secret.txt'), 'no', 'utf8') - const failure = await failureOf(endpoint().read(agent, join(outside, 'secret.txt'), {}, signal())) - expect(failure.code).toBe('workspace-file/outside-workspace') +describe('workspaceFiles.read — read access and file kinds', () => { + it('reads an absolute path outside the workspace without mutating it', async () => { + await writeFile(join(outside, 'notes.txt'), 'outside\nread only\n', 'utf8') + const write = vi.spyOn(harness.ctx.fs, 'writeText') + const edit = vi.spyOn(harness.ctx.fs, 'editText') + const result = await endpoint().read(agent, join(outside, 'notes.txt'), {}, signal()) + expect(result).toMatchObject({ text: 'outside\nread only', lines: 2, eof: true }) + expect(write).not.toHaveBeenCalled() + expect(edit).not.toHaveBeenCalled() }) - it('rejects a traversal that climbs out of the workspace', async () => { - await writeFile(join(outside, 'secret.txt'), 'no', 'utf8') - const failure = await failureOf(endpoint().read(agent, '../outside/secret.txt', {}, signal())) - expect(failure.code).toBe('workspace-file/outside-workspace') + it('resolves a relative file outside the workspace on the Host', async () => { + await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8') + expect(await endpoint().read(agent, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true }) + }) + + it('preserves a filesystem provider refusal for an outside file', async () => { + await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8') + const refusal = new FsError('backend denied read', 'FS_SANDBOX_DENIED') + vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(refusal) + await expect(endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal) }) it('rejects a symlink that points out of the workspace — the case a prefix test cannot see', async () => { diff --git a/packages/api/workspace-files/tests/stat.spec.ts b/packages/api/workspace-files/tests/stat.spec.ts index 7badc565f0..5b22d5cb0d 100644 --- a/packages/api/workspace-files/tests/stat.spec.ts +++ b/packages/api/workspace-files/tests/stat.spec.ts @@ -43,7 +43,7 @@ describe('workspaceFiles.stat', () => { await expect(harness.endpoint().stat(agent, 'notes.txt', controller.signal)).rejects.toThrow() }) - it('resolves the workspace root and then the confined target under the caller\'s signal', async () => { + it('resolves the workspace root and then the file under the caller\'s signal', async () => { await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8') const fs = harness.ctx.fs const original = fs.resolve.bind(fs) @@ -61,7 +61,7 @@ describe('workspaceFiles.stat', () => { expect(result).toEqual({ absolutePath: result.absolutePath, version: 'v-sizeless' }) }) - it('applies the read gates: symlink, directory, outside, missing, empty', async () => { + it('accepts outside files and refuses symlinks, directories, missing and empty paths', async () => { await writeFile(join(harness.outside, 'secret.txt'), 'no', 'utf8') await symlink(join(harness.outside, 'secret.txt'), join(harness.workspace, 'link.txt')) await mkdir(join(harness.workspace, 'src')) @@ -71,8 +71,7 @@ describe('workspaceFiles.stat', () => { details: { kind: 'symlink' }, }) expect((await failureOf(endpoint.stat(agent, 'src', signal()))).details).toMatchObject({ kind: 'directory' }) - expect((await failureOf(endpoint.stat(agent, join(harness.outside, 'secret.txt'), signal()))).code) - .toBe('workspace-file/outside-workspace') + expect(await endpoint.stat(agent, join(harness.outside, 'secret.txt'), signal())).toMatchObject({ bytes: 2 }) expect((await failureOf(endpoint.stat(agent, 'nope.txt', signal()))).code).toBe('workspace-file/not-found') expect((await failureOf(endpoint.stat(agent, '', signal()))).code).toBe('gateway/bad-request') }) diff --git a/packages/api/workspace-files/tsconfig.client.json b/packages/api/workspace-files/tsconfig.client.json index 43e935dc8f..70f97e7e8e 100644 --- a/packages/api/workspace-files/tsconfig.client.json +++ b/packages/api/workspace-files/tsconfig.client.json @@ -16,7 +16,6 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../gateway/tsconfig.client.json" }, - { "path": "../session-controller/tsconfig.client.json" }, { "path": "../../core/session" }, { "path": "../../client/resources" }, { "path": "../../client/ui-slots" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 33dc0d9ed4..71034eb684 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index ab6dc09a2d..48cde83037 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 24ab9fab88..e34d734834 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 4221cd9e48..6a4a9679d9 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 2b50add8d2..3fb7dfaa3b 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 33721a6727..54dd7ed873 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: 7b78e910b0cb54f0c64efb7816b428fe9f0d131c -README.zh.md: b25bb58ac031792b0f6ee0b81dfff2713668c5c4 +README.md: 186d3184b344789187e0096b49dd93c191c2fde4 +README.zh.md: fcc42c39c4489afa484844a036a5f85ef72d1e7e diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 7b78e910b0..186d3184b3 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -49,7 +49,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The opt-in [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path. -Default file editing uses `read`, `write`, and `edit`. The optional `str_replace_editor` tool is selected independently by Web minimal and `sdk-minimal`. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: +Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: ```yaml - insert: diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index b25bb58ac0..fcc42c39c4 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -49,7 +49,7 @@ kind: "package-bundle" 开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(provider 与模型可配置,你还可以在设置中启用额外 provider)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整权威日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。需主动开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。 -默认文件编辑使用 `read`、`write` 和 `edit`。Web minimal 与 `sdk-minimal` 各自选择可选的 `str_replace_editor` 工具。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: +默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: ```yaml - insert: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index d9faf89f9e..4f3407903a 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -95,6 +95,7 @@ "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-present": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 31a817873a..b8f809ebf3 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index e590b69b83..6f583fad95 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -112,11 +112,7 @@ async function bench(script: Script): Promise<{ inject: () => {}, whenIdle: () => idle, } - const agentCtx = ownerCtx.extend({ agent }) - Object.assign(agent, { - ctx: agentCtx, - }) - await options.setup?.(agentCtx) + await options.setup?.(ownerCtx, agent) script.before?.(session) ctx.agents.register(agent) return { agent, dispose: () => Promise.resolve() } diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index ee19f5c454..1f39ae000b 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index cac1c4c770..b4894c1932 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/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/bundle/sdk-minimal/README.md -README.md: 3be71c7e319f70bddda716c92ade14336db0a065 -README.zh.md: dff652f53d0a4fd6d50c216e564c46f81f4a188b +README.md: e67b1eb0637f870f47bd97ecff7133242e2f2210 +README.zh.md: 077d3ac0f603ce6781476b4850306d8f8486f82a diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 3be71c7e31..e67b1eb063 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -1,5 +1,5 @@ --- -description: "Standalone two-tool SDK profile for users who need a minimal cross-platform coding agent without the shared base bundle." +description: "Standalone single-tool SDK profile for users who need a minimal cross-platform coding agent without the shared base bundle." kind: "package-bundle" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. The profile advertises a platform-selected persistent shell and `str_replace_editor`, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and deliberately excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell and editor modify any path available to the process, so use it only with an isolated workspace. +Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. The profile advertises only a platform-selected persistent shell, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and deliberately excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, filesystem tools, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell modify any path available to the process, so use it only with an isolated workspace. ## Table of Contents @@ -46,7 +46,7 @@ The profile mounts exactly one persistent shell stack: Bash on Linux and macOS,

Implementation internals — click to expand -The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, local subprocess and unrestricted filesystem providers, a platform-selected persistent shell PTY, the string-replace editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change. +The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, local subprocess execution, a platform-selected persistent shell PTY, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change. ### Source map @@ -77,11 +77,11 @@ The bundle's single insert is the complete application tree: SDK stdio startup a #### What the model sees -The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. +The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tool is owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows; runtime context, filesystem tools, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. #### Token effect -One stable persona plus the two tool schemas. Tool results and ordinary conversation history grow with the session. +One stable persona plus one tool schema. Tool results and ordinary conversation history grow with the session. #### KV Cache effect diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index dff652f53d..077d3ac0f6 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -1,5 +1,5 @@ --- -description: "供需要不含共享 base bundle 的极简跨平台 coding agent 的用户使用的独立双工具 SDK profile。" +description: "供需要不含共享 base bundle 的极简跨平台 coding agent 的用户使用的独立单工具 SDK profile。" kind: "package-bundle" --- @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 只公布按平台选择的持久 shell 与 `str_replace_editor`,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并刻意排除 `dsh-base`、Web、settings、托管凭据、遥测、compaction、workspace 指令、skills、jobs 与 subagent。其 danger-full-access 策略允许 shell 与编辑器修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。 +当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 只公布按平台选择的持久 shell,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并刻意排除 `dsh-base`、Web、settings、托管凭据、遥测、compaction、文件系统工具、workspace 指令、skills、jobs 与 subagent。其 danger-full-access 策略允许 shell 修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。 ## 目录 @@ -46,7 +46,7 @@ dsh --profile sdk-minimal
实现细节——点击展开 -该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、本地子进程与不受限文件系统提供方、按平台选择的持久 shell PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。 +该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、本地子进程执行、按平台选择的持久 shell PTY,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。 ### 源码地图 @@ -77,11 +77,11 @@ dsh --profile sdk-minimal #### 模型看到的内容 -系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`,外加 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 +系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的唯一工具是 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`;运行时上下文、文件系统工具、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 #### Token 影响 -一个稳定 persona 加两个工具 schema。工具结果与普通对话历史随会话增长。 +一个稳定 persona 加一个工具 schema。工具结果与普通对话历史随会话增长。 #### KV Cache 影响 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index f19486e7d0..c96f07d550 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -63,13 +63,6 @@ shellDialect: pwsh timeoutMs: 300000 - # The editor uses the bare local filesystem; persistent Bash still consumes - # the shared danger-full-access sandbox policy above. - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - # Keep the minimal agent kernel explicit: unlike dsh-base, this profile # owns each service row and omits every optional producer it does not use. - id: timer @@ -135,8 +128,7 @@ description: |- Run commands in a bash shell * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. - * You don't have access to the internet via this tool. - * You do have access to a mirror of common linux and python packages via apt and pip. + * Network access depends on the task environment. Prefer configured mirrors/proxies when they are available. * State is persistent across command calls and discussions with the user. * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. * Please avoid commands that may produce a very large amount of output. @@ -156,11 +148,6 @@ * Please avoid commands that may produce a very large amount of output. * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. - - id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - - id: sessions name: '@deepseek-ai/dsh-session-persistence-jsonl' config: diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 4db08a3064..d95375faed 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", - "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", - "version": "0.1.3-alpha.2", + "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -38,7 +38,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -61,7 +60,6 @@ "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", - "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^" }, "peerDependencies": { diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index d74cba198b..39867f9d21 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -39,7 +39,6 @@ describe('dsh-sdk-minimal bundle', () => { ['pty', '@deepseek-ai/dsh-terminal'], ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'], - ['fs-local', '@deepseek-ai/dsh-fs-local'], ['timer', '@deepseek-ai/cordis-plugin-timer'], ['llm', '@deepseek-ai/dsh-llm'], ['session', '@deepseek-ai/dsh-session'], @@ -57,7 +56,6 @@ describe('dsh-sdk-minimal bundle', () => { ['agent-loop', '@deepseek-ai/dsh-agent-loop'], ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], - ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], ]) expect(rows.find(row => row.id === 'sdk-app-startup')?.config).toEqual({ profile: 'sdk-minimal' }) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 346abe5347..00e4c8b67f 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -225,10 +225,10 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-right' - # The right Sidebar's first outside tab type: workspace text files, read - # through the bounded workspaceFiles endpoint. - - id: ui-sidebar-textpreview - name: '@deepseek-ai/dsh-client-ui-sidebar-textpreview' + # The right Sidebar's document tab: bounded file reads with selectable + # Markdown, code, HTML, PDF, and plain-text renderers. + - id: ui-sidebar-documentpreview + name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' # The right Sidebar's workspace file tree tab type. - id: ui-sidebar-files diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 76076b6219..692a6be952 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -84,7 +84,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-textpreview": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 0d1633b902..6c8b1a54ac 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -23,7 +23,7 @@ How live data reaches render code, and what UI domains may share: 1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes. 2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`. 3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration. -4. **UI domains share only JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions are plain serializable data or callbacks over such data. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots). +4. **UI domains share JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions use these values. Transient file-read and document-preview content may also carry `Uint8Array`: published buffers are borrowed read-only, copied before Worker transfer, and never persisted in layout or Session JSON. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots). 5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). 6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. @@ -60,9 +60,9 @@ Npm sections describe installation and development relationships; each build fac 1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract. 2. **A package under `packages/client/` is always covered; `dsh.client` marks a Client/Host package outside that directory.** Explicit include/exclude entries handle exceptions. Every covered package's Host entry is scanned, while a `./client` export alone does not select dependency policy. -3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`. Configuration-only entries that Knip cannot infer from imports are listed in the dependency policy and projected into `knip.json` by `--fix`. +3. **Browser and type relationships are development-only.** Workspace and third-party Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, and metadata-only peers belong only in `devDependencies`. Configuration-only entries absent from imports are listed in the dependency policy; Node companion value imports follow the Host rules below. 4. **Host value imports require classified exports.** A workspace value reached from the package's Host entry belongs only in `dependencies` when its exact module specifier and runtime export appear in `safeHostDependencyExports`. Exports whose identity or module state must be shared appear in `peerRequiredHostExports` and keep the whole package edge in matching `peerDependencies` and `devDependencies`. The verifier rejects unclassified exports before `--fix` writes manifests. -5. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter. +5. **Only Host runtime libraries require installation dependencies.** Browser-only third-party implementations belong in `devDependencies`, whether inlined into `lib/client.js` or retained as bare imports in a static browser library. Static libraries are Web-shell build inputs; the final Vite build merges their imports and styles. Distributed browser code remains subject to runtime license disclosure ([decision](../../.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md)). 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 7272c047a5..9c999a025b 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 6946e7f580..1e9339761d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2,6 +2,7 @@ import { createAssistantMessage, + createSystemMessage, createToolResultMessage, createUserMessage, } from '@deepseek-ai/dsh-llm/message' @@ -686,6 +687,9 @@ function fixtureSettledStream( return stream } +/** Rendered system prompt of the fx-alpha history: surface node 0. */ +const FIXTURE_SYSTEM_PROMPT = '你是 DeepSeek Harness 的 fixture 助手。用简洁的中文回答,并在需要时调用工具。' + /** fx-alpha history script: 75 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), * mixing reasoning blocks / tool call+result / context. */ function buildAlphaLog(): SessionEvent[] { @@ -719,6 +723,13 @@ function buildAlphaLog(): SessionEvent[] { }) for (let turn = 0; turn < 60; turn++) { push({ type: 'turn/start', data: { turn } }) + // The rendered system prompt is surface node 0, ahead of the first user message. + if (turn === 0) { + push({ + type: 'system/message', surfaceOp: 'append', + data: { turn, step: 0, message: createSystemMessage(FIXTURE_SYSTEM_PROMPT, '@deepseek-ai/dsh-system-prompt') }, + }) + } const userSeq = push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`)), @@ -855,13 +866,13 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } }) const dispatchPair = (n: number, name: string, dispatchArgs: Record, resultText: string, isError = false): void => { push({ - type: 'tool/code-dispatch-start', - data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs }, + type: 'tool/ptc-dispatch-start', + data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:ptc:${n}`, name, arguments: dispatchArgs }, }) push({ - type: 'tool/code-dispatch', + type: 'tool/ptc-dispatch', data: { - rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, + rootCallId: callId, parentCallId: callId, subCallId: `${callId}:ptc:${n}`, name, arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }], }, }) @@ -1255,23 +1266,35 @@ function estimateFixtureContent(blocks: readonly ContentBlock[]): number { }, 0) } -/** Fixture parallel of token-meter's heuristic context-composition projection. */ +/** + * Fixture parallel of token-meter's heuristic context-composition projection. + * The system prompt is the system-role surface node; it prices as text plus + * role framing with no block overhead and stays out of the message figure. + */ function contextBreakdownOf(log: readonly SessionEvent[]): FixtureContextBreakdownProjection { const headerEvent = log.findLast(event => event.type === 'request/header') const header = headerEvent === undefined ? undefined : headerEvent.data.header + let systemTokens = 0 let messageTokens = 0 for (const seq of foldSurface(log).nodes) { const event = log[seq] if (event === undefined) continue const message = deriveEventMessage(event) - if (message !== null) messageTokens += estimateFixtureContent(message.content) + ROLE_OVERHEAD + if (message === null) continue + if (message.role === 'system') { + const characters = message.content.reduce( + (total, block) => total + (block.type === 'text' ? block.text.length : JSON.stringify(block).length), + 0, + ) + systemTokens = Math.ceil(characters / CHARS_PER_TOKEN) + ROLE_OVERHEAD + continue + } + messageTokens += estimateFixtureContent(message.content) + ROLE_OVERHEAD } return { - systemTokens: header?.system === undefined - ? 0 - : Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD, + systemTokens, toolsTokens: header?.tools === undefined || header.tools.length === 0 ? 0 : Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD, @@ -1420,6 +1443,7 @@ function projectionFramesOf( }) } if (type === 'request/header' + || type === 'system/message' || type === 'user/message' || type === 'assistant/message' || type === 'tool/result') { diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index d3afc8c960..88f661754c 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -963,7 +963,10 @@ describe('createFixtureApi', () => { goal: null, imageLimits: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 }, }) - expect((alpha?.values['contextBreakdown'] as { messageTokens: number }).messageTokens).toBeGreaterThan(0) + const breakdown = alpha?.values['contextBreakdown'] as { systemTokens: number; messageTokens: number } + expect(breakdown.messageTokens).toBeGreaterThan(0) + // The seeded system/message at surface node 0 prices the system figure. + expect(breakdown.systemTokens).toBeGreaterThan(0) expect((alpha?.values['sessionStats'] as { steps: number }).steps).toBeGreaterThan(0) expect(second.value.projections['fx-alpha']).toEqual(alpha) diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index f1fc6cac73..015a125351 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 548165f045..6295fa479f 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index e07156c47d..10177f91e2 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index f10adf664e..d65a1c82f0 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -12,6 +12,7 @@ import { createLanguageRowStore } from '../src/client/settings-store.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -41,7 +42,7 @@ function mount(active = 'en') { const props: LanguageRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 27eddaa12b..1c09baf8a6 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/resources/README.i18n.yaml b/packages/client/resources/README.i18n.yaml index 34a20c84f9..235d02c651 100644 --- a/packages/client/resources/README.i18n.yaml +++ b/packages/client/resources/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/resources/README.md -README.md: 7c32293c8d4250b11f8beaaf473a62145c915bce -README.zh.md: b53089a2449f379f43156f610b587656a998ece3 +README.md: 6b5afc82230be6930c41a01a584aeed39f596c37 +README.zh.md: 6b928883bb54cd4663efa7a20a7bc768d4f8399e diff --git a/packages/client/resources/README.md b/packages/client/resources/README.md index 7c32293c8d..6b5afc8223 100644 --- a/packages/client/resources/README.md +++ b/packages/client/resources/README.md @@ -33,12 +33,12 @@ Nothing needs configuration to mount: the plugin provides `ctx.resources` and co ### Read a resource -Every slot component receives `useResource` in its props. `useResource

(address)` names the protocol as the type argument and returns `{ status, value, failure, reload }`: `none` when no provider is registered for the address's protocol (or the address is not a `dsh-resource://` URL), `loading` while the provider has not yielded, `live` with the latest `ok` frame's value, and `failed` when the latest frame reported a failure, with that failure beside the last value. `reload()` asks the provider for a fresh value and is a no-op without one. Subscribing through the hook is what holds the resource open; a component that mounts while another holder keeps the resource alive reads the latest value at once. +Every slot component receives `useResource` in its props. `useResource

(address)` names the protocol as the type argument and returns `{ status, value, failure }`: `none` when no provider is registered for the address's protocol (or the address is not a `dsh-resource://` URL), `loading` while the provider has not yielded, `live` with the latest `ok` frame's value, and `failed` when the latest frame reported a failure, with that failure beside the last value. Subscribing through the hook is what holds the resource open; a component that mounts while another holder keeps the resource alive reads the latest value at once. ### Provide a protocol -The protocol's owning client package declares its value type in `ResourceProtocolMap` and registers one provider as an owned effect. `open` yields `RemoteResult` frames: the current content first and one frame per later change, with a failure as an `ok: false` frame rather than a throw; it must stop when `signal` aborts. `reload` is optional: +The protocol's owning client package declares its value type in `ResourceProtocolMap` and registers one provider as an owned effect. `open` yields `RemoteResult` frames: the current content first and one frame per later change, with a failure as an `ok: false` frame rather than a throw; it must stop when `signal` aborts: ```ts ignore-check declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -54,7 +54,6 @@ export function apply(ctx) { yield await readNote(address, signal) for await (const change of followNote(address, signal)) yield change }, - reload(address) { requestReread(address) }, }), 'my-notes: note resource provider') } ``` @@ -72,7 +71,7 @@ A protocol has exactly one provider; a second registration throws. Registering a ### Lifecycle -One record per address holds a snapshot store, a holder count (hook subscribers plus pins), and the running stream's `AbortController`. The first holder opens the provider's stream; every later holder shares it; the last holder's release aborts the stream and resets the snapshot to idle (`loading` with a provider, `none` without). Records are kept for the page lifetime so `source()` stays reference-stable across React's render-then-subscribe window and a StrictMode remount. `reload` is one function per record and never changes. +One record per address holds a snapshot store, a holder count (hook subscribers plus pins), and the running stream's `AbortController`. The first holder opens the provider's stream; every later holder shares it; the last holder's release aborts the stream and resets the snapshot to idle (`loading` with a provider, `none` without). Records are kept for the page lifetime so `source()` stays reference-stable across React's render-then-subscribe window and a StrictMode remount. ### Failures diff --git a/packages/client/resources/README.zh.md b/packages/client/resources/README.zh.md index b53089a244..6b928883bb 100644 --- a/packages/client/resources/README.zh.md +++ b/packages/client/resources/README.zh.md @@ -33,12 +33,12 @@ kind: "package-reference" ### 读取资源 -每个 slot 组件都在 props 上收到 `useResource`。`useResource

(address)` 以类型参数命名协议,返回 `{ status, value, failure, reload }`:地址协议没有提供方(或地址不是 `dsh-resource://` URL)时为 `none`,提供方尚未产出值时为 `loading`,`live` 携带最新一个 `ok` 帧的值,`failed` 表示最新一帧报告了失败,失败放在最后一个值旁。`reload()` 请提供方给一个新值,没有提供方时是空操作。通过 hook 订阅就是钉住资源的方式;另一个持有者让资源保持存活时,新挂载的组件立刻读到最新值。 +每个 slot 组件都在 props 上收到 `useResource`。`useResource

(address)` 以类型参数命名协议,返回 `{ status, value, failure }`:地址协议没有提供方(或地址不是 `dsh-resource://` URL)时为 `none`,提供方尚未产出值时为 `loading`,`live` 携带最新一个 `ok` 帧的值,`failed` 表示最新一帧报告了失败,失败放在最后一个值旁。通过 hook 订阅就是钉住资源的方式;另一个持有者让资源保持存活时,新挂载的组件立刻读到最新值。 ### 提供协议 -协议所属的客户端包在 `ResourceProtocolMap` 声明其值类型,并以自有 effect 注册一个提供方。`open` 产出 `RemoteResult` 帧:先是当前内容,之后每次变化一帧,失败以 `ok: false` 帧而非抛错表达;必须在 `signal` 中止时停止。`reload` 可选: +协议所属的客户端包在 `ResourceProtocolMap` 声明其值类型,并以自有 effect 注册一个提供方。`open` 产出 `RemoteResult` 帧:先是当前内容,之后每次变化一帧,失败以 `ok: false` 帧而非抛错表达;必须在 `signal` 中止时停止: ```ts ignore-check declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -54,7 +54,6 @@ export function apply(ctx) { yield await readNote(address, signal) for await (const change of followNote(address, signal)) yield change }, - reload(address) { requestReread(address) }, }), 'my-notes: note resource provider') } ``` @@ -72,7 +71,7 @@ export function apply(ctx) { ### 生命周期 -每个地址一条记录,持有一个快照 store、一个持有者计数(hook 订阅者加 pin)与运行中流的 `AbortController`。第一个持有者打开提供方的流;之后的持有者共享它;最后一个持有者释放时中止流并把快照重置为空闲(有提供方为 `loading`,没有为 `none`)。记录在页面存续期内保留,使 `source()` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定。`reload` 每条记录一个函数,永不变化。 +每个地址一条记录,持有一个快照 store、一个持有者计数(hook 订阅者加 pin)与运行中流的 `AbortController`。第一个持有者打开提供方的流;之后的持有者共享它;最后一个持有者释放时中止流并把快照重置为空闲(有提供方为 `loading`,没有为 `none`)。记录在页面存续期内保留,使 `source()` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定。 ### 失败 diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index cf240c9af8..cdcd4eed2d 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/resources/src/client/contract.ts b/packages/client/resources/src/client/contract.ts index c297c172b0..dd5b7bef6b 100644 --- a/packages/client/resources/src/client/contract.ts +++ b/packages/client/resources/src/client/contract.ts @@ -50,8 +50,6 @@ export interface ResourceSnapshot { readonly value: Value | undefined /** The latest frame's failure; present only while `status` is `failed`. */ readonly failure: RemoteFailure | undefined - /** Ask the provider for a fresh frame; a no-op when its protocol has no provider or no `reload`. */ - readonly reload: () => void } /** @@ -84,11 +82,6 @@ export interface ResourceProvider

{ * @returns the frame stream; it must stop once `ctx.signal` aborts. */ open(address: string, ctx: ResourceOpenContext): AsyncIterable> - /** - * Produce a fresh frame on the open stream. Absent when the protocol has no refresh. - * @param address - the full address, a `dsh-resource:///…` URL. - */ - reload?(address: string): void } /** diff --git a/packages/client/resources/src/client/resources.ts b/packages/client/resources/src/client/resources.ts index 5b9a75cabe..614387cfd7 100644 --- a/packages/client/resources/src/client/resources.ts +++ b/packages/client/resources/src/client/resources.ts @@ -23,7 +23,6 @@ import type { interface RuntimeProvider { readonly protocol: string open(address: string, ctx: ResourceOpenContext): AsyncIterable> - reload?(address: string): void } /** One address: its state, its holders, and the running stream. */ @@ -33,7 +32,6 @@ interface ResourceRecord { readonly protocol: string | undefined readonly store: SnapshotStore> readonly source: ObservableSnapshot> - readonly reload: () => void /** Subscribers plus pins; the stream runs while this is positive. */ holders: number /** Present while the provider's stream runs; aborting it ends the stream. */ @@ -69,8 +67,8 @@ export function protocolOf(address: string): string | undefined { return parsed.hostname === '' ? undefined : parsed.hostname.toLowerCase() } -function idle(status: 'none' | 'loading', reload: () => void): ResourceSnapshot { - return { status, value: undefined, failure: undefined, reload } +function idle(status: 'none' | 'loading'): ResourceSnapshot { + return { status, value: undefined, failure: undefined } } /** The `ctx.resources` implementation. */ @@ -120,17 +118,13 @@ export class ResourceRegistry implements Resources { private create(address: string): ResourceRecord { const protocol = protocolOf(address) - const reload = (): void => { - this.providerOf(protocol)?.reload?.(address) - } const store = createSnapshotStore>( - idle(this.providerOf(protocol) === undefined ? 'none' : 'loading', reload), + idle(this.providerOf(protocol) === undefined ? 'none' : 'loading'), ) const record: ResourceRecord = { address, protocol, store, - reload, holders: 0, controller: undefined, source: { @@ -170,7 +164,7 @@ export class ResourceRegistry implements Resources { record.holders -= 1 if (record.holders > 0) return this.stop(record) - record.store.set(idle(this.providerOf(record.protocol) === undefined ? 'none' : 'loading', record.reload)) + record.store.set(idle(this.providerOf(record.protocol) === undefined ? 'none' : 'loading')) } /** The provider arrived: a held record opens its stream, an idle one turns `loading`. */ @@ -179,13 +173,13 @@ export class ResourceRegistry implements Resources { this.start(record) return } - record.store.set(idle('loading', record.reload)) + record.store.set(idle('loading')) } /** The provider left: the stream ends and the record reports `none`. */ private detach(record: ResourceRecord): void { this.stop(record) - record.store.set(idle('none', record.reload)) + record.store.set(idle('none')) } private start(record: ResourceRecord): void { @@ -193,7 +187,7 @@ export class ResourceRegistry implements Resources { if (provider === undefined) return const controller = new AbortController() record.controller = controller - if (record.store.getSnapshot().status !== 'loading') record.store.set(idle('loading', record.reload)) + if (record.store.getSnapshot().status !== 'loading') record.store.set(idle('loading')) void this.consume(record, provider, controller.signal) } @@ -210,8 +204,8 @@ export class ResourceRegistry implements Resources { // to nobody; ending the loop also returns the iterator. if (signal.aborted) break record.store.set(frame.ok - ? { status: 'live', value: frame.value, failure: undefined, reload: record.reload } - : { status: 'failed', value: record.store.getSnapshot().value, failure: frame.error, reload: record.reload }) + ? { status: 'live', value: frame.value, failure: undefined } + : { status: 'failed', value: record.store.getSnapshot().value, failure: frame.error }) } } } diff --git a/packages/client/resources/tests/apply.client.spec.ts b/packages/client/resources/tests/apply.client.spec.ts index 810ba7dbc1..6431d0485f 100644 --- a/packages/client/resources/tests/apply.client.spec.ts +++ b/packages/client/resources/tests/apply.client.spec.ts @@ -4,17 +4,21 @@ * hook reaches every slot component as `useResource`, and both leave with the * fiber so a reload of the plugin registers cleanly again. */ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { act } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { apply, inject, type ResourceSnapshot, type UseResource } from '../src/client/index.ts' import { apply as hostApply } from '../src/index.ts' import { ResourceRegistry } from '../src/client/resources.ts' +import type { ResourceProvider } from '../src/client/contract.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { 'resources.probe': { kind: 'single'; scope: 'root' } + 'resources.sessionProbe': { kind: 'single'; scope: 'session' } + 'resources.sessionPeer': { kind: 'single'; scope: 'session' } } interface ResourceProtocolMap { feed: string @@ -33,7 +37,11 @@ const settle = (): Promise => new Promise((resolve) => { setTimeout(resolv async function boot(): Promise { const rt = await SlotTestRuntime.create() - await rt.declare({ 'resources.probe': { kind: 'single', scope: 'root' } }) + await rt.declare({ + 'resources.probe': { kind: 'single', scope: 'root' }, + 'resources.sessionProbe': { kind: 'single', scope: 'session' }, + 'resources.sessionPeer': { kind: 'single', scope: 'session' }, + }) return rt } @@ -95,4 +103,63 @@ describe('client-resources apply', () => { await runtime.mount({ inject: [...inject], apply }) expect(hook).toBeTypeOf('function') }) + + it('shares one address across Root and Session components without reopening on selection', async () => { + runtime = await boot() + const rt = runtime + const firstId = await rt.sessions.add({ id: 'first-session' }) + const secondId = await rt.sessions.add({ id: 'second-session' }, { current: false }) + await rt.mount({ inject: [...inject], apply }) + const opened = Promise.withResolvers() + const open = vi.fn['open']>(async function* () { + try { yield { ok: true as const, value: 'shared data' } } finally { opened.resolve(undefined) } + }) + await act(async () => { + rt.ctx.effect(() => rt.ctx.resources.register({ protocol: 'feed', open }), 'spec: shared address') + }) + const source = rt.ctx.resources.source(A) + const seen: { + root?: ResourceSnapshot + first?: ResourceSnapshot + second?: ResourceSnapshot + firstSession?: string + secondSession?: string + } = {} + rt.slots.register({ name: 'resources.probe' }, ({ useResource }: PropsRuntime<'resources.probe'>) => { + seen.root = useResource<'feed'>(A) + return null + }) + rt.slots.register({ name: 'resources.sessionProbe' }, ({ sessionId, useResource }: PropsRuntime<'resources.sessionProbe'>) => { + seen.firstSession = sessionId + seen.first = useResource<'feed'>(A) + return null + }) + rt.slots.register({ name: 'resources.sessionPeer' }, ({ sessionId, useResource }: PropsRuntime<'resources.sessionPeer'>) => { + seen.secondSession = sessionId + seen.second = useResource<'feed'>(A) + return null + }) + rt.renderSlot('resources.probe', {}) + rt.renderSlot('resources.sessionProbe', {}) + rt.renderSlot('resources.sessionPeer', {}) + await act(async () => { await opened.promise }) + const snapshot = source.getSnapshot() + expect(snapshot).toEqual({ status: 'live', value: 'shared data', failure: undefined }) + expect(seen.root).toBe(snapshot) + expect(seen.first).toBe(snapshot) + expect(seen.second).toBe(snapshot) + expect([seen.firstSession, seen.secondSession]).toEqual([firstId, firstId]) + expect(open).toHaveBeenCalledTimes(1) + expect(open.mock.calls[0]![0]).toBe(A) + expect(open.mock.calls[0]![1]).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal }) + + await rt.sessions.setCurrent(secondId) + expect([seen.firstSession, seen.secondSession]).toEqual([secondId, secondId]) + expect(rt.ctx.resources.source(A)).toBe(source) + expect(seen.root).toBe(snapshot) + expect(seen.first).toBe(snapshot) + expect(seen.second).toBe(snapshot) + expect(open).toHaveBeenCalledTimes(1) + expect(open.mock.calls[0]![1].signal.aborted).toBe(false) + }) }) diff --git a/packages/client/resources/tests/resources.client.spec.ts b/packages/client/resources/tests/resources.client.spec.ts index bc04ca2d7a..1fee63fe9d 100644 --- a/packages/client/resources/tests/resources.client.spec.ts +++ b/packages/client/resources/tests/resources.client.spec.ts @@ -4,7 +4,7 @@ * Providers are scripted feeds so every transition is driven by the spec, * never by timing. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -14,6 +14,7 @@ import type { ResourceOpenContext, ResourceProvider } from '../src/client/contra declare module '@deepseek-ai/dsh-client-ui-slots' { interface ResourceProtocolMap { feed: string + counter: number } } @@ -27,6 +28,7 @@ interface Feed { end(): void /** Whether the consumer returned the iterator (its `finally` ran). */ readonly returned: boolean + readonly closed: Promise } type Step = { readonly kind: 'frame'; readonly frame: RemoteResult } | { readonly kind: 'end' } @@ -35,6 +37,7 @@ function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterab const steps: Step[] = [] let wake: (() => void) | undefined let returned = false + const closed = Promise.withResolvers() const notify = (): void => { wake?.(); wake = undefined } async function* stream(): AsyncGenerator> { try { @@ -47,10 +50,12 @@ function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterab } } finally { returned = true + closed.resolve(undefined) } } const feed: Feed = { ctx, + closed: closed.promise, push: (value) => { steps.push({ kind: 'frame', frame: { ok: true, value } }); notify() }, fail: (error) => { steps.push({ kind: 'frame', frame: { ok: false, error } }); notify() }, end: () => { steps.push({ kind: 'end' }); notify() }, @@ -62,6 +67,10 @@ function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterab /** A `feed` provider whose every `open` is recorded and spec-driven. */ function scriptedProvider() { const opens: Feed[] = [] + onTestFinished(async () => { + for (const feed of opens) feed.end() + await Promise.all(opens.map(feed => feed.closed)) + }) const provider = { protocol: 'feed' as const, open: vi.fn((_address: string, ctx: ResourceOpenContext) => { @@ -69,7 +78,6 @@ function scriptedProvider() { opens.push(feed) return stream }), - reload: vi.fn(), } satisfies ResourceProvider<'feed'> return { provider, opens, last: () => opens[opens.length - 1]! } } @@ -333,27 +341,97 @@ describe('ResourceRegistry streams', () => { }) }) -describe('ResourceRegistry reload', () => { - it('forwards reload to the protocol\'s provider, and stays a no-op without one', () => { +describe('ResourceRegistry addresses', () => { + it('opens different complete addresses independently and supplies only the lifetime signal', async () => { const b = bench() - b.snapshot().reload() b.registry.register(b.provider) - b.snapshot().reload() - expect(b.provider.reload).toHaveBeenCalledWith(A) - - const bare = bench() - bare.registry.register({ protocol: 'feed', open: bare.provider.open }) - expect(() => { bare.snapshot().reload() }).not.toThrow() + const other = A + '?variant=second' + const first = b.registry.source(A) + const second = b.registry.source(other) + const releaseFirst = first.subscribe(() => {}) + const firstFeed = b.last() + const releaseSecond = second.subscribe(() => {}) + const secondFeed = b.last() + expect(first).not.toBe(second) + expect(b.provider.open.mock.calls).toEqual([[A, firstFeed.ctx], [other, secondFeed.ctx]]) + expect(firstFeed.ctx).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal }) + expect(secondFeed.ctx).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal }) + firstFeed.push('first data') + secondFeed.push('second data') + await vi.waitFor(() => { expect(first.getSnapshot().value).toBe('first data') }) + await vi.waitFor(() => { expect(second.getSnapshot().value).toBe('second data') }) + releaseFirst() + expect(firstFeed.ctx.signal.aborted).toBe(true) + expect(secondFeed.ctx.signal.aborted).toBe(false) + expect(second.getSnapshot().value).toBe('second data') + releaseSecond() + expect(secondFeed.ctx.signal.aborted).toBe(true) }) +}) - it('keeps one reload function per address across state changes', async () => { +describe('ResourceRegistry stream generations', () => { + it.each(['value', 'failure'] as const)('drops late %s frames after a released resource reopens', async (kind) => { const b = bench() b.registry.register(b.provider) const source = b.registry.source(A) - const { reload } = source.getSnapshot() - source.subscribe(() => {}) - b.last().push('v1') - await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') }) - expect(source.getSnapshot().reload).toBe(reload) + const releaseFirst = source.subscribe(() => {}) + const oldFeed = b.last() + oldFeed.push('old data') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('old data') }) + releaseFirst() + const releaseCurrent = source.subscribe(() => {}) + const currentFeed = b.last() + expect(currentFeed).not.toBe(oldFeed) + currentFeed.push('current data') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('current data') }) + const current = source.getSnapshot() + if (kind === 'value') oldFeed.push('late old data') + else oldFeed.fail(new RemoteError('gateway/internal', 'late old failure', {})) + await oldFeed.closed + expect(oldFeed.returned).toBe(true) + expect(source.getSnapshot()).toBe(current) + releaseCurrent() + }) + + it.each(['value', 'failure'] as const)('drops old-provider %s frames after replacement', async (kind) => { + const b = bench() + const releaseProvider = b.registry.register(b.provider) + const source = b.registry.source(A) + const unsubscribe = source.subscribe(() => {}) + const oldFeed = b.last() + oldFeed.push('old data') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('old data') }) + releaseProvider() + expect(oldFeed.ctx.signal.aborted).toBe(true) + expect(source.getSnapshot()).toEqual({ status: 'none', value: undefined, failure: undefined }) + const replacement = scriptedProvider() + b.registry.register(replacement.provider) + expect(source.getSnapshot()).toEqual({ status: 'loading', value: undefined, failure: undefined }) + replacement.last().push('replacement data') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('replacement data') }) + const current = source.getSnapshot() + if (kind === 'value') oldFeed.push('late old data') + else oldFeed.fail(new RemoteError('gateway/internal', 'late old failure', {})) + await oldFeed.closed + expect(oldFeed.returned).toBe(true) + expect(source.getSnapshot()).toBe(current) + unsubscribe() + }) + + it('streams another protocol as plain numbers', async () => { + const b = bench() + const closed = Promise.withResolvers() + const dispose = b.registry.register({ + protocol: 'counter', + async *open() { + try { yield { ok: true as const, value: 1 } } finally { closed.resolve(undefined) } + }, + }) + onTestFinished(dispose) + const source = b.registry.source('dsh-resource://counter/one') + const unsubscribe = source.subscribe(() => {}) + await closed.promise + expect(source.getSnapshot()).toEqual({ status: 'live', value: 1, failure: undefined }) + unsubscribe() }) }) diff --git a/packages/client/store/README.i18n.yaml b/packages/client/store/README.i18n.yaml index d6af2417f1..657b1a396d 100644 --- a/packages/client/store/README.i18n.yaml +++ b/packages/client/store/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/store/README.md -README.md: 9d675c1189ae194b913293c89904b4c4f39c879e -README.zh.md: 3ff170b71c320688e50c02c20479ee1d94061a87 +README.md: 0d4525ab6eb2a58bdea89673b96f9aaf5e0e2cf8 +README.zh.md: b66a2e31675c82d933526d40ed9eba20bc5077d9 diff --git a/packages/client/store/README.md b/packages/client/store/README.md index 9d675c1189..0d4525ab6e 100644 --- a/packages/client/store/README.md +++ b/packages/client/store/README.md @@ -32,6 +32,7 @@ None; the stores neither assemble nor send model requests. - **Persistence is browser-local** — persisted stores use JSON in `localStorage`; non-browser runtimes disable persistence, and the package provides no cross-device synchronization. +- **Web-shell build input** — the static ESM retains third-party imports for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). diff --git a/packages/client/store/README.zh.md b/packages/client/store/README.zh.md index 3ff170b71c..b66a2e3167 100644 --- a/packages/client/store/README.zh.md +++ b/packages/client/store/README.zh.md @@ -32,6 +32,7 @@ kind: "package-library" - **持久化仅限浏览器本地**——持久化 store 使用 `localStorage` 中的 JSON;非浏览器运行时会禁用持久化,本包也不提供跨设备同步。 +- **Web 壳构建输入**——静态 ESM 为 Vite 保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 diff --git a/packages/client/store/package.json b/packages/client/store/package.json index acbea6850b..6bb0c0a212 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -22,15 +22,13 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { - "immer": "^10.1.1", - "zustand": "~4.4.7" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "zustand": "~4.4.7", + "immer": "^10.1.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 5e414c8bf2..9984d47613 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index a4dbdd949b..f14ef62142 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -39,7 +39,7 @@ export const en: Record = { 'Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', presetMinimalDescription: - 'Two-tool coding agent with persistent bash and str_replace_editor.', + 'Single-tool coding agent with a persistent shell.', presetCordisName: 'Creator mode', presetCordisDescription: 'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.', @@ -98,7 +98,7 @@ export const zh: Record = { presetPtcName: 'PTC 模式', presetPtcDescription: '功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。', presetMinimalName: '极简模式', - presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。', + presetMinimalDescription: '仅提供持久 shell 的单工具编码 Agent。', presetCordisName: '创造模式', presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。', duplicate: '复制', diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 52653b5c10..b4cecaa31d 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 0b39f11322..9adf41af13 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -42,9 +42,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1", @@ -58,7 +55,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^" + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 7da3a878e9..3e86094c74 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -12,6 +12,7 @@ import { MessageImages } from '../src/client/MessageImages.tsx' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -272,7 +273,7 @@ describe('ImageGallery', () => { sessionId: 'message-images-test' as MessageImagesProps['sessionId'], useSession, useSessions, - useResource, + usePanelInfo, useResource, useSessionPendingInteraction, useWorkspaces, useProjection: () => undefined, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 173f0c0eef..0db54ad4f0 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 40a2dc51ea..4db27677d0 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 62b6a8c8644953010ba61dc19780c5e8a5a01991 -README.zh.md: 9aa8cec85bde83130ecf06806f9831d491f09ea5 +README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8 +README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 62b6a8c864..860dcb9eeb 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -10,6 +10,8 @@ English | [中文](README.zh.md) Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Compact display folds completed-turn process rows while keeping the final answer and independently useful context visible; packed historical Assistant runs remain collapsed. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests. +File-mention providers receive the viewed Session ID with the closing-turn owner, so links into inherited history can address the fork itself. + ## Table of Contents - [System prompt row](#system-prompt-row) @@ -25,9 +27,7 @@ Use this package to render a browser chat from recorded Session conversations, i ## System prompt row -Chat shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, real system-field change, or non-initial request whose preceding header is outside the loaded history window. Once that predecessor is available, an unchanged resume does not repeat the row; same-series config-only or tool-only changes, tool steps, and retries also create no repetition. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A header without a system prompt creates no row. - ------ +Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives. ## Turn token usage diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 9aa8cec85b..4dd06a16b1 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -10,6 +10,8 @@ kind: "package-reference" 使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript 与 steering 提交会立即显示并保留在原区域,在权威 Session 记录到达时原子地消失,而 queued 提交始终不进入 Chat。本包不组装或修改模型请求。 +文件引用提供方同时接收当前查看的 Session ID 与收尾 turn 的属主信息,因此继承历史中的链接可以指向 fork 自身。 + ## 目录 - [系统提示词行](#system-prompt-row) @@ -25,7 +27,7 @@ kind: "package-reference" ## 系统提示词行 -Chat 会为非空的初始请求、显式消息序列起点、真实 system 字段变化,或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的`系统提示词`。前序 header 到达后,内容未变的 resume 不会重复该行;同一序列内仅配置或仅工具变化、工具步骤与重试也不会重复。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。没有系统提示词的 header 不创建该行。 +每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的`系统提示词`。即使系统文本未变,resume 也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。 ----- diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 0d8375a8a6..7a5cb18771 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -68,7 +68,7 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-textpreview": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 2d8de50561..ef23ade8bb 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -7,7 +7,7 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' // The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below. -import type {} from '@deepseek-ai/dsh-client-ui-sidebar-textpreview/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' // Type-only service and declaration merges used by the apply world. import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -116,14 +116,14 @@ export function apply(ctx: Context): void { chatNode: key => chat.getSnapshot().nodes.source(key), chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key), }, - fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner), + fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId), // Files open in the right Sidebar, not in a desktop application: the // content stays in the product, beside the conversation that produced // it. A relative path, or an absolute one inside the session's // workspace, is addressed under this session's scope, - // `dsh-resource://file/session//`; an absolute path - // elsewhere is addressed as `dsh-resource://file/absolute/` and - // read through the session on screen. Which tab type claims the + // `dsh-resource://file/session//`; an absolute path + // elsewhere keeps its absolute spelling in the same Session's address. + // Which tab type claims the // address is the Sidebar's decision, not this call site's. // A line travels as a navigation parameter, not as part of the // address: the file is one piece of content whether it is opened at diff --git a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx index 26c43b1793..787c8fb7bf 100644 --- a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx +++ b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx @@ -8,6 +8,8 @@ import css from './ContextInjectionRow.module.css' export interface SystemPromptRowProps { /** Complete model-visible prompt text. */ text: string + /** True when the prompt replaced an earlier one from this position in the history. */ + update?: boolean /** The owning view's locale seat. */ t: ChatViewSlotProps['t'] } @@ -15,18 +17,19 @@ export interface SystemPromptRowProps { /** * Render one complete system prompt as a collapsed disclosure whose expanded * body is the same opaque context chrome: 141px code-block scrollport and - * model-facing text with its real line breaks. - * @param props - Complete prompt text and the locale seat. + * model-facing text with its real line breaks. An in-history update uses the + * same row under its own title. + * @param props - Complete prompt text, whether it is an update, and the locale seat. * @returns The system-prompt disclosure row. */ -export function SystemPromptRow({ text, t }: SystemPromptRowProps) { +export function SystemPromptRow({ text, update = false, t }: SystemPromptRowProps) { const [open, setOpen] = useState(false) return ( } chevronClassName={css.chevron} - title={t('message.systemPrompt')} + title={t(update ? 'message.systemPromptUpdate' : 'message.systemPrompt')} open={open} expandable expandOnRowClick @@ -43,5 +46,5 @@ export function SystemPromptRow({ text, t }: SystemPromptRowProps) { export const SystemPromptNodeView = memo(function SystemPromptNodeView({ node, t, }: Pick, 'node' | 't'>) { - return + return }) diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 3fb8bcfcee..b5c3a45b99 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -1,6 +1,6 @@ /** Chat-owned Slot declarations and composed component props. */ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, @@ -53,9 +53,10 @@ export interface ChatFileMentions { /** * Resolve prose links for one closing Turn. * @param owner - closing-Turn identity and file opener. + * @param sessionId - viewed Session, including when history is inherited from a fork. * @returns link resolver when available. */ - forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined + forClosing(owner: TurnTailOwnerProps, sessionId: SessionId): MarkdownFileMentions | undefined } declare module '@deepseek-ai/cordis' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts index e41c3ec334..fd14a8115c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts @@ -1,14 +1,15 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RequestPromptInspector, + SystemPromptState, SystemPromptInspector, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNode } from '../contract/chat-nodes.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { - /** Complete system prompt rendered for one model request. */ - 'system-prompt': { readonly text: string } + /** Complete system prompt rendered for one model request, or an in-history prompt update at its own position. */ + 'system-prompt': { readonly text: string; readonly update?: true } } } @@ -19,7 +20,7 @@ interface RequestPromptState extends ReturnType { readonly step?: number } -/** Place a request's system field at the start of its visible message series. */ +/** Place a request's system prompt at the start of its visible message series. */ function requestPromptAnchor( match: ConversationMatch, previous: Readonly | undefined, @@ -48,7 +49,41 @@ function stableRequestPromptAnchor( } /** - * Request-header prompt Definition for the Chat target. + * System-prompt surface node Definition for the Chat target. It owns every + * `system/message` event on the Chat target so the unknown-surface fallback + * never renders the prompt as a transcript row. Each nonempty append owns a + * prompt card, even without a loaded request header. Initial cards precede + * their step's input; in-history updates stay at their own positions. The + * request-prompt Definition owns replacement and later-series cards. Positional + * replacements advance the effective prompt without changing historical cards. + * @param inspect - Pure surface interpretation supplied by uiConversation. + * @returns The Chat system-prompt Definition. + */ +export function systemMessageDefinition(inspect: SystemPromptInspector): ConversationNodeDefinition { + return { + kind: 'system-message', + target: 'chat', + match: event => event.type === 'system/message' + || ('surfaceOp' in event && event.surfaceOp !== 'append') + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => { + return inspect(reader.previous('system-message')?.state, match.event) + }, + update: context => context.state, + buildViewNode: (context) => { + const state = context.state?.introduced + if (state === undefined || state.text === '' + || context.start?.event.type !== 'system/message' || context.start.event.surfaceOp !== 'append') return null + const anchor = state.update ? state.seq : requestPromptAnchor(context.start, undefined, true) + return chatNode(context, 'system-prompt', anchor, { text: state.text, ...state.update ? { update: true } : {} }) + }, + } +} + +/** + * Request-header prompt Definition for the Chat target. Resume and explicit + * series starts retain a prompt card even when the system text is unchanged. * @param inspect - the shared prompt interpretation, supplied by the * uiConversation service (a client bundle cannot value-import it). * @returns the Chat request-prompt Definition. @@ -65,11 +100,20 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver throw new Error('request-prompt start requires request/header') } const previous = reader.previous('request-prompt')?.state + const systemContext = reader.previous('system-message') + const system = systemContext?.state.effective const location = match.location.kind === 'step' ? { turn: match.location.turn.turn, step: match.location.step.step } : {} - const inspection = inspect(previous?.prompt, match.event) + const inspection = inspect(previous?.prompt, match.event, system) const change = inspection.change?.kind + // Appended prompts own their cards; a same-step header must not repeat them. + const systemEvent = systemContext?.matches[0]?.event + const shownByUpdate = system !== undefined + && systemEvent?.type === 'system/message' && systemEvent.surfaceOp === 'append' + && (system.update || previous === undefined) + && system.turn === location.turn + && system.step === location.step return { anchorSeq: stableRequestPromptAnchor( context, @@ -77,11 +121,11 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver previous, match.event.data.reason === 'initial', ), - showsPrompt: previous === undefined - || match.event.data.reason === 'series' + showsPrompt: !shownByUpdate && (previous === undefined + || match.event.data.reason !== 'change' || match.event.data.startsSeries === true || change === 'system' - || change === 'system-and-tools', + || change === 'system-and-tools'), ...location, ...inspection, } @@ -105,11 +149,14 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver } /** - * Register model-request system prompts in the Chat flow. + * Register the system-prompt surface node and the model-request prompt card in the Chat flow. * @param ctx - Owning UI Conversation context. */ export function registerRequestPromptConversationNode(ctx: Context): void { + ctx.uiConversation.events.register(systemMessageDefinition( + (previous, event) => ctx.uiConversation.inspectSystemPrompt(previous, event), + )) ctx.uiConversation.events.register(requestPromptDefinition( - (previous, event) => ctx.uiConversation.inspectRequestPrompt(previous, event), + (previous, event, system) => ctx.uiConversation.inspectRequestPrompt(previous, event, system), )) } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index acfcb4bca8..15f93b1465 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -139,13 +139,13 @@ function acceptsEdge(state: ToolState, parent: string, child: string): boolean { function updateDispatch(state: ToolState, match: ConversationMatch): ToolState { const event = match.event - if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state + if (event.type !== 'tool/ptc-dispatch-start' && event.type !== 'tool/ptc-dispatch') return state const data = event.data const parentCallId = String(data.parentCallId) const subCallId = String(data.subCallId) const siblings = state.children.get(parentCallId) ?? [] const index = siblings.findIndex(candidate => candidate.callId === subCallId) - if (event.type === 'tool/code-dispatch-start') { + if (event.type === 'tool/ptc-dispatch-start') { if (index >= 0 || !acceptsEdge(state, parentCallId, subCallId)) return state const children = new Map(state.children) children.set(parentCallId, [...siblings, childCall(match, data)]) @@ -227,7 +227,7 @@ function fallbackState(context: ConversationNodeContext): ToolState | return state } -/** Root Tool lifecycle and nested Code Dispatch Definition. */ +/** Root Tool lifecycle and nested PTC dispatch Definition. */ export const toolDefinition: ConversationNodeDefinition = { kind: 'tool-call', target: 'chat', @@ -236,7 +236,7 @@ export const toolDefinition: ConversationNodeDefinition = { if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { return { id: String(event.data.message.source.callId), role: 'update' } } - if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { + if (event.type === 'tool/ptc-dispatch-start' || event.type === 'tool/ptc-dispatch') { const rootCallId: unknown = event.data.rootCallId return typeof rootCallId === 'string' && rootCallId !== '' ? { id: rootCallId, role: 'update' } diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 4da8226a3c..fcae47bcef 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -35,6 +35,7 @@ export const zh = { 'fileOpen.unknown': '无法打开此文件', 'message.extraBlock': '附加内容块', 'message.systemPrompt': '系统提示词', + 'message.systemPromptUpdate': '系统提示词更新', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', 'message.referenceSummary': '引用会话 · {labels}', @@ -144,6 +145,7 @@ export const en = { 'fileOpen.unknown': 'Couldn’t open this file', 'message.extraBlock': 'Extra content block', 'message.systemPrompt': 'System prompt', + 'message.systemPromptUpdate': 'System prompt update', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', 'message.referenceSummary': 'Referenced session · {labels}', diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index 878a78e63a..8426873941 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -22,7 +22,7 @@ function sameReferences( } /** - * Owns Code Dispatch pairing and projects its private parent index into the + * Owns PTC dispatch pairing and projects its private parent index into the * recursive Tool call contract exposed by conversation snapshots. */ export class ToolCallTree { @@ -50,12 +50,12 @@ export class ToolCallTree { } /** - * Fold one event when it belongs to the Code Dispatch lifecycle. + * Fold one event when it belongs to the PTC dispatch lifecycle. * @param event - Session event from the current live or history window. * @returns Whether the event was consumed as a child-call lifecycle event. */ apply(event: SessionEvent): boolean { - if (event.type === 'tool/code-dispatch-start') { + if (event.type === 'tool/ptc-dispatch-start') { const data = event.data const running: RunningToolCall = { callId: data.subCallId, @@ -73,7 +73,7 @@ export class ToolCallTree { this.revision++ return true } - if (event.type !== 'tool/code-dispatch') return false + if (event.type !== 'tool/ptc-dispatch') return false const data = event.data const siblings = this.childrenByParent.get(data.parentCallId) ?? [] const at = siblings.findIndex(sub => sub.callId === data.subCallId) diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 590cc5602c..1e8f4e12f7 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -16,6 +16,7 @@ import { apply as applyChat, inject as injectChat, type ChatViewInjected, } from '@deepseek-ai/dsh-client-ui-chat/client' import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createChatStore } from '../src/client/stores.ts' usePinnedBrowserLanguages('zh-CN') @@ -57,7 +58,11 @@ async function bench() { ) new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => ROOT), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(ROOT) + runtime.sessions.open(ROOT) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const session = sessionFakeFor() await runtime.sessions.add({ @@ -69,7 +74,7 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) await runtime.mount({ inject: [...injectChat], apply: applyChat }) @@ -149,9 +154,9 @@ describe('Chat inject API', () => { // Session; the Client need not know it. await injected.openFile('src/a.ts') expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/root-2/src/a.ts') - // An absolute path outside every known root carries no Session in its address. + // An absolute path outside every known root still names its Session. await injected.openFile('/abs/a.ts') - expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/absolute/abs/a.ts') + expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-2//abs/a.ts') await b.runtime.dispose() }) @@ -177,7 +182,7 @@ describe('Chat inject API', () => { const forClosing = vi.fn(() => mentions) b.runtime.ctx.provide('chatFileMentions', { forClosing } as never) expect(injected.fileMentions(owner)).toBe(mentions) - expect(forClosing).toHaveBeenCalledWith(owner) + expect(forClosing).toHaveBeenCalledWith(owner, ROOT) expect(injected.chatScroll.read()).toBeNull() const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 } diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index fc1cf43bc5..ac364e4cb7 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -9,6 +9,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { apply as applyConversation, inject as injectConversation, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -44,7 +45,11 @@ async function bench() { runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) new TestRemote(runtime.ctx, { session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })) }, @@ -53,7 +58,7 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, 'conversation.approval.detail': { kind: 'single', scope: 'session' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) @@ -120,7 +125,8 @@ describe('Chat apply wiring', () => { await b.chat.dispose() expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0) expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined() - expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation']) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1) expect(b.runtime.ctx.get('uiConversation')).toBeDefined() await b.runtime.dispose() }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 7b72d280dc..5e9033bdd0 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -45,7 +45,7 @@ import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] afterEach(() => { cleanup() @@ -364,6 +364,7 @@ function makeHarness( // ChatView never invokes it (pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: ChatViewSlotProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, useSession: bindSnapshotSelector(session.source), useChat: bindSnapshotSelector(chatSource.source), diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 658e7e80fb..85fa2b6eb0 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -11,6 +11,7 @@ import { type ConversationViewDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { inspectSystemPrompt } from '../../ui-conversation/src/client/contract/system-prompt.ts' import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' @@ -22,7 +23,7 @@ import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fall import { nextStepInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' import { messageDefinition } from '../src/client/conversation-nodes/message.ts' import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { requestPromptDefinition } from '../src/client/conversation-nodes/request-prompt.ts' +import { requestPromptDefinition, systemMessageDefinition } from '../src/client/conversation-nodes/request-prompt.ts' import { retryDefinition } from '../src/client/conversation-nodes/retry.ts' import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' @@ -36,6 +37,7 @@ import type { const DEFINITIONS: readonly ConversationNodeDefinition[] = [ nextStepInboxDefinition, messageDefinition, + systemMessageDefinition(inspectSystemPrompt), requestPromptDefinition(inspectRequestPrompt), assistantDefinition, turnProcessDefinition, @@ -172,6 +174,27 @@ function textMessage(id: string, text: string) { } } +function systemMessage(text: string) { + return { + id: `system-${text}`, + role: 'system', + content: text === '' ? [] : [{ type: 'text', text }], + source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, + } +} + +/** Append the first system prompt node or replace the node at `replaces`. */ +function systemAt(seq: number, text: string, replaces?: number): SessionLiveEventEntry { + return at(seq, 'system/message', { turn: 1, step: 1, message: systemMessage(text) }, replaces === undefined + ? { surfaceOp: 'append' } + : { surfaceOp: { op: 'replace', startSeq: replaces, endSeq: replaces }, sourceEventSeqs: [replaces] }) +} + +/** Append an in-history prompt update the way the loop does on an `in-history` route. */ +function systemUpdateAt(seq: number, text: string, turn: number, step: number): SessionLiveEventEntry { + return at(seq, 'system/message', { turn, step, message: systemMessage(text) }, { surfaceOp: 'append' }) +} + function assistantMessage(id: string, text: string) { return { id, @@ -208,6 +231,19 @@ describe('built-in conversation node Definitions', () => { .toThrow('request-prompt start requires request/header') }) + it('pins the system-message Definition edges the engine cannot reach', () => { + const input = at(1, 'turn/start', { turn: 1 }) + const invalidStart = { + ...input, + role: 'start' as const, + location: { kind: 'session' as const }, + } + const state = { seq: 1, time: 1, turn: 1, step: 1, text: '# System', update: false } + + expect(systemMessageDefinition(inspectSystemPrompt).match(invalidStart.event)).toBeNull() + expect(systemMessageDefinition(inspectSystemPrompt).update({ state } as never, invalidStart)).toBe(state) + }) + it('keeps ordinary command-only history inactive for the Conversation shell', () => { const value = assembler([ at(1, 'command/run', { @@ -1086,14 +1122,14 @@ describe('built-in conversation node Definitions', () => { }) const history = assembler([ - at(14, 'tool/code-dispatch-start', { + at(14, 'tool/ptc-dispatch-start', { rootCallId: 'history-root', parentCallId: 'history-root', subCallId: 'child', name: 'read', arguments: { path: 'README.md' }, }), - at(15, 'tool/code-dispatch', { + at(15, 'tool/ptc-dispatch', { rootCallId: 'history-root', parentCallId: 'history-root', subCallId: 'child', @@ -1133,7 +1169,7 @@ describe('built-in conversation node Definitions', () => { ]) const firstChild = (after?.data as ToolChatData).root.subCalls[0] - history.append(at(17, 'tool/code-dispatch-start', { + history.append(at(17, 'tool/ptc-dispatch-start', { rootCallId: 'history-root', parentCallId: 'history-root', subCallId: 'second-child', @@ -1145,6 +1181,55 @@ describe('built-in conversation node Definitions', () => { expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild) }) + it('joins mixed historical and current subcall IDs by explicit fields through replay', () => { + const historicalId = 'other-root:code:1' + const currentId = 'other-root:ptc:2' + const historical = { + rootCallId: 'root', parentCallId: 'root', subCallId: historicalId, + name: 'run_code', arguments: {}, + } + const current = { + rootCallId: 'root', parentCallId: historicalId, subCallId: currentId, + name: 'read', arguments: { file_path: 'README.md' }, + } + const events = [ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'run_code', arguments: '{}' }), + at(4, 'tool/call', { turn: 1, step: 1, callId: 'other-root', name: 'run_code', arguments: '{}' }), + at(5, 'tool/ptc-dispatch-start', historical), + at(6, 'tool/ptc-dispatch-start', current), + at(7, 'tool/ptc-dispatch', { ...current, isError: false, content: [{ type: 'text', text: 'contents' }] }), + at(8, 'tool/ptc-dispatch', { ...historical, isError: false, content: [] }), + ] + const expected = { + callId: 'root', + subCalls: [{ + kind: 'tool-result', callId: historicalId, parentCallId: 'root', callTime: events[4]!.event.time, + subCalls: [{ + kind: 'tool-result', callId: currentId, parentCallId: historicalId, callTime: events[5]!.event.time, + content: [{ type: 'text', text: 'contents' }], subCalls: [], + }], + }], + } + const live = assembler(events.slice(0, 4)) + for (const event of events.slice(4)) live.append(event) + live.flush() + const replay = assembler(events.slice(4), true) + replay.prepend(events.slice(0, 4), false) + replay.flush() + for (const value of [live, replay]) { + const view = snapshot(value) + const roots = view.order.flatMap((key) => { + const entry = view.nodes.get(key) + return entry?.kind === 'tool-call' ? [(entry.data as ToolChatData).root] : [] + }) + expect(roots).toHaveLength(2) + expect(roots.find(root => root.callId === 'root')).toMatchObject(expected) + expect(roots.find(root => root.callId === 'other-root')?.subCalls).toEqual([]) + } + }) + it('prepends an older turn without replacing already materialized nodes', () => { const value = assembler([ at(20, 'turn/start', { turn: 2 }), @@ -1353,69 +1438,82 @@ describe('built-in conversation node Definitions', () => { }) }) - it('materializes series starts and system changes but not unchanged resumes, config, or tool changes', () => { + it('materializes series starts and system node replacements but not same-series config or tool changes', () => { const tools = [{ name: 'read', description: 'Read', parameters: { type: 'object' } }] const expandedTools = [...tools, { name: 'write', description: 'Write', parameters: { type: 'object' } }] const value = assembler([ - at(1, 'request/header', { - reason: 'initial', - header: { config: { provider: 'fake', model: 'fake' }, system: '# Initial', tools }, - }), + systemAt(1, '# Initial'), at(2, 'request/header', { - reason: 'change', - header: { - config: { provider: 'fake', model: 'fake' }, - system: '# Initial', - tools: expandedTools, - }, + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, tools }, }), at(3, 'request/header', { reason: 'change', - header: { - config: { provider: 'fake', model: 'fake', maxTokens: 1_024 }, - system: '# Initial', - tools: expandedTools, - }, + header: { config: { provider: 'fake', model: 'fake' }, tools: expandedTools }, }), at(4, 'request/header', { reason: 'change', - startsSeries: true, - header: { - config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, - system: '# Initial', - tools: expandedTools, - }, + header: { config: { provider: 'fake', model: 'fake', maxTokens: 1_024 }, tools: expandedTools }, }), at(5, 'request/header', { - reason: 'resume', - header: { - config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, - system: '# Initial', - tools: expandedTools, - }, + reason: 'change', + startsSeries: true, + header: { config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, tools: expandedTools }, }), at(6, 'request/header', { + reason: 'resume', + header: { config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, tools: expandedTools }, + }), + systemAt(7, '# Updated', 1), + at(8, 'request/header', { reason: 'change', - header: { - config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, - system: '# Updated', - tools: expandedTools, - }, + header: { config: { provider: 'fake', model: 'fake', maxTokens: 4_096 }, tools: expandedTools }, }), ]) - const prompts = snapshot(value).nodes.values() + const current = snapshot(value) + const prompts = current.nodes.values() .filter(candidate => candidate.kind === 'system-prompt') expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([ { anchorSeq: 1, data: { text: '# Initial' } }, - { anchorSeq: 4, data: { text: '# Initial' } }, - { anchorSeq: 6, data: { text: '# Updated' } }, + { anchorSeq: 5, data: { text: '# Initial' } }, + { anchorSeq: 6, data: { text: '# Initial' } }, + { anchorSeq: 8, data: { text: '# Updated' } }, ]) + expect(current.nodes.values().filter(candidate => candidate.kind === 'unknown')).toEqual([]) + }) + it('shows a complete appended prompt at the start of a headerless window', () => { + const value = assembler([ + systemUpdateAt(10, '# Known prompt', 2, 1), + at(11, 'user/message', textMessage('window-user', 'continue'), { surfaceOp: 'append' }), + ], true) + const current = snapshot(value) + expect(current.nodes.values().filter(candidate => candidate.kind === 'system-prompt') + .map(candidate => candidate.data)).toEqual([{ text: '# Known prompt' }]) + expect(current.nodes.values().filter(candidate => candidate.kind === 'unknown')).toEqual([]) + value.prepend([ + systemAt(1, '# Original'), + at(2, 'request/header', { reason: 'initial', header: { config: { provider: 'fake', model: 'fake' } } }), + ], false) + value.flush() + const restored = snapshot(value) + expect(restored.order.map(key => restored.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt') + .map(candidate => candidate?.data)).toEqual([{ text: '# Original' }, { text: '# Known prompt', update: true }]) + }) + + it('withholds windowed replacement prompts until prepend resolves their positions', () => { const windowed = assembler([ - at(10, 'request/header', { + systemAt(10, '# Resumed prompt', 5), + at(11, 'request/header', { reason: 'resume', - header: { config: { provider: 'fake', model: 'fake' }, system: '# Resumed prompt' }, + header: { config: { provider: 'fake', model: 'fake' } }, + }), + ], true) + const nodeless = assembler([ + at(11, 'request/header', { + reason: 'resume', + header: { config: { provider: 'fake', model: 'fake' } }, }), ], true) const systemless = assembler([ @@ -1424,41 +1522,45 @@ describe('built-in conversation node Definitions', () => { header: { config: { provider: 'fake', model: 'fake' } }, }), ]) - expect(node(snapshot(windowed), 'system-prompt')?.data).toEqual({ text: '# Resumed prompt' }) + expect(node(snapshot(windowed), 'system-prompt')).toBeUndefined() + expect(node(snapshot(nodeless), 'system-prompt')).toBeUndefined() expect(node(snapshot(systemless), 'system-prompt')).toBeUndefined() - windowed.prepend([ - at(5, 'request/header', { + const older = [ + systemAt(5, '# Original prompt'), + at(6, 'request/header', { reason: 'initial', - header: { config: { provider: 'fake', model: 'fake' }, system: '# Resumed prompt' }, + header: { config: { provider: 'fake', model: 'fake' } }, }), - ], false) + ] + const promptTexts = (value: ConversationNodeAssembler) => { + const restored = snapshot(value) + return restored.order.flatMap((key) => { + const candidate = restored.nodes.get(key) + return candidate?.kind === 'system-prompt' ? [candidate.data] : [] + }) + } + windowed.prepend(older, false) windowed.flush() - const restored = snapshot(windowed) - const restoredPrompts = restored.nodes.values() - .filter(candidate => candidate.kind === 'system-prompt') - expect(restoredPrompts.map(prompt => ({ - anchorSeq: prompt.anchorSeq, - visibility: prompt.visibility, - data: prompt.data, - })).sort((left, right) => left.anchorSeq - right.anchorSeq)).toEqual([ - { anchorSeq: 5, visibility: 'visible', data: { text: '# Resumed prompt' } }, - { anchorSeq: 10, visibility: 'hidden', data: { text: '# Resumed prompt' } }, - ]) + nodeless.prepend(older, false) + nodeless.flush() + expect(promptTexts(windowed)).toEqual([{ text: '# Original prompt' }, { text: '# Resumed prompt' }]) + expect(promptTexts(nodeless)).toEqual([{ text: '# Original prompt' }, { text: '# Original prompt' }]) }) - it('orders the system field before the request messages while preserving message order', () => { + it('shows the system node text as the request prompt card before the request messages', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'step/start', { turn: 1, step: 1 }), - at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }), - at(4, 'user/message', { + systemAt(3, '# System\n\nFollow instructions.'), + at(4, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }), + at(5, 'user/message', { ...textMessage('runtime-context', 'runtime facts'), source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt', form: 'snapshot' }, }, { surfaceOp: 'append' }), - at(5, 'request/header', { + at(6, 'request/header', { reason: 'initial', - header: { config: { provider: 'fake', model: 'fake' }, system: '# System' }, + header: { config: { provider: 'fake', model: 'fake' } }, }), ]) @@ -1469,20 +1571,175 @@ describe('built-in conversation node Definitions', () => { 'context', ]) expect(node(current, 'system-prompt')?.anchorSeq).toBe(1) + expect(node(current, 'system-prompt')?.data).toEqual({ text: '# System\n\nFollow instructions.' }) + }) + + it.each(['replay', 'live', 'partial'] as const)('restores A when compaction shadows B without a new system event (%s)', (mode) => { + const history = [ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'system/message', { turn: 1, step: 1, message: systemMessage('A') }, { surfaceOp: 'append' }), + at(4, 'request/header', { + reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [] }, + }), + at(5, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('a', 'a') }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'step/start', { turn: 1, step: 2 }), + at(8, 'system/message', { turn: 1, step: 2, message: systemMessage('B') }, { surfaceOp: 'append' }), + at(9, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('b', 'b') }), + at(10, 'step/end', { turn: 1, step: 2 }), + at(11, 'step/start', { turn: 1, step: 3 }), + at(12, 'user/message', { + turn: 1, step: 3, id: 'summary', role: 'user', + content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compaction' }, + }, { surfaceOp: { op: 'replace', startSeq: 5, endSeq: 9 }, sourceEventSeqs: [5, 8, 9] }), + at(13, 'request/header', { + reason: 'series', header: { config: { provider: 'test', model: 'test' }, tools: [] }, + }), + at(14, 'assistant/message', { turn: 1, step: 3, message: assistantMessage('restored', 'restored') }), + at(15, 'step/end', { turn: 1, step: 3 }), + ] + const value = assembler(mode === 'replay' ? history : []) + if (mode === 'partial') { + value.replaceWindow(history.slice(7), true) + value.flush() + expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt').map(candidate => candidate.data)) + .toEqual([{ text: 'B' }]) + value.prepend(history.slice(0, 7), false) + value.flush() + } + if (mode === 'live') { + for (const entry of history) { + value.append(entry) + value.flush() + } + } + const current = snapshot(value) + expect(current.order.map(key => current.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt') + .map(candidate => candidate?.data)).toEqual([ + { text: 'A' }, { text: 'B', update: true }, { text: 'A' }, + ]) + }) + + it('withholds reversed unknown replacement endpoints and resolves them after prepend', () => { + const value = assembler([ + systemAt(6, 'C', 3), systemAt(7, 'D', 5), + at(8, 'request/header', { reason: 'resume', header: { config: { provider: 'test', model: 'test' } } }), + ], true) + expect(node(snapshot(value), 'system-prompt')).toBeUndefined() + const uncertain = assembler([systemAt(6, 'C', 3), systemUpdateAt(7, 'Known but unordered', 1, 2)], true) + expect(node(snapshot(uncertain), 'system-prompt')).toBeUndefined() + value.prepend([systemAt(1, 'A'), systemAt(3, 'B'), systemAt(5, 'A2', 1)], false) + value.flush() + expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt') + .map(candidate => candidate.data)).toEqual([{ text: 'A' }, { text: 'B', update: true }, { text: 'C' }]) + }) + + it('never renders a system/message as a transcript bubble', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + systemAt(3, '# System'), + at(4, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }), + ]) + + const current = snapshot(value) + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user']) + + value.append(systemAt(5, '# Replaced', 3)) + value.flush() + const replaced = snapshot(value) + expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user']) + }) + + it('presents an in-history prompt update as its own card and lets no same-step header repeat it', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + systemAt(3, '# System'), + at(4, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), + at(5, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, tools: [] }, + }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(8, 'turn/start', { turn: 2 }), + at(9, 'step/start', { turn: 2, step: 1 }), + systemUpdateAt(10, '# Updated', 2, 1), + at(11, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }), + ]) + const cards = () => { + const current = snapshot(value) + return current.order.flatMap((key) => { + const candidate = current.nodes.get(key) + return candidate?.kind === 'system-prompt' ? [[candidate.anchorSeq, candidate.data]] : [] + }) + } + + // The update is the model-visible change at its position; node 0 keeps its card. + expect(cards()).toEqual([ + [1, { text: '# System' }], + [10, { text: '# Updated', update: true }], + ]) + + // A series header in the same step shows nothing more: the update card already carries the text. + value.append(at(12, 'request/header', { + reason: 'series', + startsSeries: true, + header: { config: { provider: 'fake', model: 'fake' }, tools: [] }, + })) + value.flush() + expect(cards()).toHaveLength(2) + + // A later series header presents the effective prompt again, as any series start does. + value.append(at(13, 'step/end', { turn: 2, step: 1 })) + value.append(at(14, 'step/start', { turn: 2, step: 2 })) + value.append(at(15, 'request/header', { + reason: 'series', + startsSeries: true, + header: { config: { provider: 'fake', model: 'fake' }, tools: [] }, + })) + value.flush() + expect(cards()).toEqual([ + [1, { text: '# System' }], + [10, { text: '# Updated', update: true }], + [14, { text: '# Updated' }], + ]) + }) + + it('renders no card for an in-history update that clears the prompt', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + systemAt(3, '# System'), + at(4, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), + at(5, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, tools: [] }, + }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'step/start', { turn: 1, step: 2 }), + systemUpdateAt(8, '', 1, 2), + ]) + + const current = snapshot(value) + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user']) }) it('keeps the initial system prompt before the opening User as Turn process state changes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'step/start', { turn: 1, step: 1 }), - at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }), - at(4, 'user/message', { + systemAt(3, '# System'), + at(4, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }), + at(5, 'user/message', { ...textMessage('runtime-context', 'runtime facts'), source: { kind: 'plugin', plugin: 'context' }, }, { surfaceOp: 'append' }), - at(5, 'request/header', { + at(6, 'request/header', { reason: 'initial', - header: { config: { provider: 'fake', model: 'fake' }, system: '# System' }, + header: { config: { provider: 'fake', model: 'fake' } }, }), ]) const kinds = () => { @@ -1493,7 +1750,7 @@ describe('built-in conversation node Definitions', () => { expect(kinds()).toEqual(['system-prompt', 'user', 'context']) - value.append(at(6, 'assistant/live-chunk', { + value.append(at(7, 'assistant/live-chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, })) value.flush() @@ -1501,13 +1758,13 @@ describe('built-in conversation node Definitions', () => { 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', ]) - value.append(at(7, 'step/end', { turn: 1, step: 1 })) - value.append(at(8, 'step/start', { turn: 1, step: 2 })) - value.append(at(9, 'assistant/message', { + value.append(at(8, 'step/end', { turn: 1, step: 1 })) + value.append(at(9, 'step/start', { turn: 1, step: 2 })) + value.append(at(10, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), }, { surfaceOp: 'append' })) - value.append(at(10, 'step/end', { turn: 1, step: 2 })) - value.append(at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } })) + value.append(at(11, 'step/end', { turn: 1, step: 2 })) + value.append(at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } })) value.flush() expect(kinds()).toEqual([ @@ -1520,16 +1777,17 @@ describe('built-in conversation node Definitions', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'step/start', { turn: 1, step: 1 }), - at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), - at(4, 'request/header', { + systemAt(3, '# System'), + at(4, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), + at(5, 'request/header', { reason: 'initial', - header: { config: { provider: 'fake', model: 'fake' }, system: '# System' }, + header: { config: { provider: 'fake', model: 'fake' } }, }), - at(5, 'step/end', { turn: 1, step: 1 }), - at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), - at(7, 'turn/start', { turn: 2 }), - at(8, 'step/start', { turn: 2, step: 1 }), - at(9, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(8, 'turn/start', { turn: 2 }), + at(9, 'step/start', { turn: 2, step: 1 }), + at(10, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }), ]) const current = snapshot(value) @@ -1540,35 +1798,35 @@ describe('built-in conversation node Definitions', () => { expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user']) }) - it('keeps a windowed System prompt in place when prepend supplies the preceding header', () => { + it('places a withheld replacement prompt after prepend supplies its original node', () => { const reasons = ['change', 'resume', 'series'] as const for (const reason of reasons) { const windowedSystem = reason === 'series' ? '# Original' : '# Windowed' const windowed = assembler([ - at(5, 'turn/start', { turn: 2 }), - at(6, 'step/start', { turn: 2, step: 1 }), - at(7, 'user/message', textMessage(`second-user-${reason}`, 'second'), { surfaceOp: 'append' }), - at(8, 'request/header', { + at(6, 'turn/start', { turn: 2 }), + at(7, 'step/start', { turn: 2, step: 1 }), + systemAt(8, windowedSystem, 3), + at(9, 'user/message', textMessage(`second-user-${reason}`, 'second'), { surfaceOp: 'append' }), + at(10, 'request/header', { reason, - header: { config: { provider: 'fake', model: 'fake' }, system: windowedSystem }, + header: { config: { provider: 'fake', model: 'fake' } }, }), ], true) const before = snapshot(windowed) const prompt = node(before, 'system-prompt') const user = node(before, 'user') - if (prompt === undefined || user === undefined) throw new Error('windowed prompt fixture is incomplete') - const stableOrder = [user.key, prompt.key] - expect(prompt.anchorSeq).toBe(8) - expect(before.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder) + expect(prompt).toBeUndefined() + if (user === undefined) throw new Error('windowed user fixture is incomplete') windowed.prepend([ at(1, 'turn/start', { turn: 1 }), at(2, 'step/start', { turn: 1, step: 1 }), - at(3, 'user/message', textMessage(`first-user-${reason}`, 'first'), { surfaceOp: 'append' }), - at(4, 'request/header', { + systemAt(3, '# Original'), + at(4, 'user/message', textMessage(`first-user-${reason}`, 'first'), { surfaceOp: 'append' }), + at(5, 'request/header', { reason: 'initial', - header: { config: { provider: 'fake', model: 'fake' }, system: '# Original' }, + header: { config: { provider: 'fake', model: 'fake' } }, }), ], false) windowed.flush() @@ -1578,9 +1836,9 @@ describe('built-in conversation node Definitions', () => { const candidate = restored.nodes.get(key) return candidate?.kind === 'system-prompt' ? [candidate] : [] }) - expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 8]) - expect(restored.nodes.get(prompt.key)?.anchorSeq).toBe(8) - expect(restored.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder) + expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 10]) + expect(prompts.at(-1)?.data).toEqual({ text: windowedSystem }) + expect(restored.nodes.get(user.key)).toBeDefined() } }) @@ -1588,27 +1846,28 @@ describe('built-in conversation node Definitions', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'step/start', { turn: 1, step: 1 }), - at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), - at(4, 'request/header', { + systemAt(3, '# Same'), + at(4, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), + at(5, 'request/header', { reason: 'initial', - header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' }, + header: { config: { provider: 'fake', model: 'fake' } }, }), - at(5, 'user/message', { + at(6, 'user/message', { ...textMessage('compacted', 'summary'), source: { kind: 'plugin', plugin: 'compact' }, - }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }), - at(6, 'request/header', { + }, { surfaceOp: { op: 'replace', startSeq: 4, endSeq: 4 } }), + at(7, 'request/header', { reason: 'series', - header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' }, + header: { config: { provider: 'fake', model: 'fake' } }, }), - at(7, 'step/end', { turn: 1, step: 1 }), - at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), - at(9, 'turn/start', { turn: 2 }), - at(10, 'step/start', { turn: 2, step: 1 }), - at(11, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }), - at(12, 'request/header', { + at(8, 'step/end', { turn: 1, step: 1 }), + at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(10, 'turn/start', { turn: 2 }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(12, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }), + at(13, 'request/header', { reason: 'series', - header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' }, + header: { config: { provider: 'fake', model: 'fake' } }, }), ]) @@ -1621,7 +1880,7 @@ describe('built-in conversation node Definitions', () => { 'system-prompt', 'user', 'system-prompt', 'system-prompt', 'user', ]) expect(ordered.filter(candidate => candidate?.kind === 'system-prompt') - .map(candidate => candidate?.anchorSeq)).toEqual([1, 6, 9]) + .map(candidate => candidate?.anchorSeq)).toEqual([1, 7, 10]) }) it('associates each direct message with its immediately following session recall', () => { @@ -1777,18 +2036,18 @@ describe('built-in conversation node Definitions', () => { at(3, 'user/message', { ...textMessage('replacement-user', 'model-only context'), source: { kind: 'plugin', plugin: 'foreign' }, - }, { surfaceOp: { op: 'replace', start: 1, end: 1 } }), + }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 1 } }), at(4, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('replacement-assistant', 'rewritten answer'), - }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }), + }, { surfaceOp: { op: 'replace', startSeq: 2, endSeq: 2 } }), at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }), at(6, 'tool/result', { turn: 1, step: 1, message: toolResult('root', 'pruned result'), - }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }), + }, { surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 } }), ]) const current = snapshot(value) @@ -1869,7 +2128,7 @@ describe('built-in conversation node Definitions', () => { compactionId: 'manual-1', sourceCommandId: 'command-1', }, - }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }), + }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 2 } }), at(14, 'compaction/end', { compactionId: 'manual-1', sourceCommandId: 'command-1', @@ -1890,7 +2149,7 @@ describe('built-in conversation node Definitions', () => { at(22, 'user/message', { ...textMessage('automatic-checkpoint', 'checkpoint'), source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' }, - }, { surfaceOp: { op: 'replace', start: 3, end: 4 } }), + }, { surfaceOp: { op: 'replace', startSeq: 3, endSeq: 4 } }), at(23, 'compaction/end', { compactionId: 'automatic-1', turn: null }), ]) @@ -1909,7 +2168,7 @@ describe('built-in conversation node Definitions', () => { at(13, 'user/message', { ...textMessage('checkpoint', 'checkpoint'), source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' }, - }, { surfaceOp: { op: 'replace', start: 1, end: 8 } }), + }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 8 } }), ], true) const before = node(snapshot(value), 'compaction') expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null }) @@ -1950,7 +2209,7 @@ describe('built-in conversation node Definitions', () => { at(11, 'user/message', { ...textMessage('checkpoint-windowed', 'checkpoint'), source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' }, - }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }), + }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 3 } }), ], true) expect(node(snapshot(value), 'compaction')?.data).toMatchObject({ @@ -1974,14 +2233,14 @@ describe('built-in conversation node Definitions', () => { at(22, 'user/message', { ...textMessage('legacy-checkpoint', 'checkpoint'), source: { kind: 'plugin', plugin: 'compact' }, - }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }), + }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 3 } }), at(23, 'compaction/end', { turn: null }), ], true) expect(node(snapshot(value), 'compaction')).toBeUndefined() }) - it('ignores legacy retry and code-dispatch events without correlation ids', () => { + it('ignores legacy retry and PTC dispatch events without correlation ids', () => { const value = assembler([ at(10, 'llm/retry', { turn: 1, @@ -2006,13 +2265,13 @@ describe('built-in conversation node Definitions', () => { delayMs: 10, failure: { code: 'TRANSPORT', message: 'second legacy retry' }, }), - at(30, 'tool/code-dispatch-start', { + at(30, 'tool/ptc-dispatch-start', { parentCallId: 'root', subCallId: 'child', name: 'legacy-subcall', arguments: {}, }), - at(31, 'tool/code-dispatch', { + at(31, 'tool/ptc-dispatch', { parentCallId: 'root', subCallId: 'child', name: 'legacy-subcall', @@ -2157,10 +2416,10 @@ describe('built-in conversation node Definitions', () => { it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => { const value = assembler([ - at(12, 'tool/code-dispatch-start', { + at(12, 'tool/ptc-dispatch-start', { rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' }, }), - at(13, 'tool/code-dispatch', { + at(13, 'tool/ptc-dispatch', { rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' }, isError: false, content: [{ type: 'text', text: 'child result' }], }), @@ -2184,7 +2443,7 @@ describe('built-in conversation node Definitions', () => { compactionId: 'manual-1', sourceCommandId: 'command-1', }, - }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }), + }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 2 } }), at(22, 'command/done', { commandId: 'command-1', kind: 'success', diff --git a/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx b/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx index e91524f633..1f8c07a3d6 100644 --- a/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx @@ -41,4 +41,22 @@ describe('SystemPromptNodeView', () => { expect(disclosure.getAttribute('aria-expanded')).toBe('false') expect(container.querySelector('[data-system-prompt-body]')).toBeNull() }) + + it('titles an in-history prompt update as an update of the same row', () => { + const node: ChatNode<'system-prompt'> = { + key: 'system-message:10', + kind: 'system-prompt', + id: '10', + target: 'chat', + anchorSeq: 10, + location: { kind: 'unresolved' }, + visibility: 'visible', + data: { text: '# Updated rules', update: true }, + } + const { container } = render() + + const disclosure = screen.getByRole('button', { name: 'System prompt update' }) + fireEvent.click(disclosure) + expect(container.querySelector('[data-context-text]')?.textContent).toBe('# Updated rules') + }) }) diff --git a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts index d53c34e69c..fbfbcb1c47 100644 --- a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts @@ -9,12 +9,12 @@ const at = (seq: number, type: string, data: Record): SessionEv ({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent const start = (seq: number, parentCallId: string, subCallId: string): SessionEvent => - at(seq, 'tool/code-dispatch-start', { + at(seq, 'tool/ptc-dispatch-start', { parentCallId, subCallId, name: 'run_code', arguments: {}, }) const settle = (seq: number, parentCallId: string, subCallId: string): SessionEvent => - at(seq, 'tool/code-dispatch', { + at(seq, 'tool/ptc-dispatch', { parentCallId, subCallId, name: 'run_code', arguments: {}, isError: false, content: [], }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index 20a18b0889..288bdce31d 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -29,12 +29,13 @@ function noPendingInteraction() { } // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | typeof zh = en) { const source = createSnapshotStore(mode) const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) const props: TranscriptViewRowProps = { + usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), useSessionPendingInteraction: noPendingInteraction(), useWorkspaces: emptyWorkspaces(), diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index b1ad52d680..fb56acd661 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -99,7 +99,7 @@ "path": "../ui-workspace" }, { - "path": "../ui-sidebar-textpreview" + "path": "../ui-sidebar-documentpreview" } ] } diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index f4d557914c..40ce5a3bff 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -64,7 +61,8 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 79765da08c..a1eea2e97a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 06667e15dd44c4bcaaea9212d52bb0eda8867c6e -README.zh.md: ce2b183e6fffcf00485856c8eed777368b446ac6 +README.md: 19a210bb200dae359920e85ff944b68242f45b4b +README.zh.md: c5a56871eb99607c924b2b2e048de72d918e6a8b diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 06667e15dd..19a210bb20 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -31,12 +31,14 @@ The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its o A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. -Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. +Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request inspection serves every target: `ctx.uiConversation.inspectSystemPrompt(previous, event)` interprets system messages and positional replacements as immutable loaded-surface state. It selects the last nonempty surviving system node in surface order, retains only surviving replacement positions for chained rewrites, and withholds the prompt after an unindexed older endpoint until prepend replay supplies its order. Target-owned Definitions retain historical cards independently. `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies request changes against that effective prompt; ordinary messages and stream chunks require no system-state work. ## Shell and standard props -The package registers the optional-Session `conversation` shell, strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. +Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. + +The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index ce2b183e6f..c5a56871eb 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -31,12 +31,14 @@ adapter 把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 -target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。 +target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。 ## Shell 与标准 props -本包注册 optional-Session `conversation` shell、strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 +工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在请求仍有效时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 + +本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index dd2c75dbee..987aa9574f 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -46,13 +46,7 @@ }, "license": "MIT", "dependencies": { - "clsx": "^2.0.0", - "@deepseek-ai/schemastery": "workspace:^", - "@lexical/history": "^0.49.0", - "@lexical/plain-text": "^0.49.0", - "@lexical/text": "^0.49.0", - "@lexical/utils": "^0.49.0", - "lexical": "^0.49.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" @@ -93,7 +87,13 @@ "react-dom": "^18.2.0", "@types/react-dom": "~18.3.0", "zod": "^4.4.3", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "lexical": "^0.49.0", + "clsx": "^2.0.0", + "@lexical/utils": "^0.49.0", + "@lexical/text": "^0.49.0", + "@lexical/plain-text": "^0.49.0", + "@lexical/history": "^0.49.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 65b8374919..7eba82cfc0 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -28,6 +28,7 @@ import { queueDockEntry } from './queue/QueueDock.tsx' import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx' import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' +import { ConversationPanel } from './skeleton/ConversationPanel.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' @@ -84,9 +85,11 @@ const ABSENT_FILE_UPLOADS = { } interface WorkspaceNavigation { - connectWorkspace( + openSession(sessionId: SessionId): void + openWorkspace( workspaceId: Parameters[0], - ): Promise + beforeOpen: (sessionId: SessionId) => void, + ): Promise } /** Resolve the session-scoped Conversation action face, failing loud. */ @@ -214,7 +217,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) const registerConversationRoot = () => slots.register({ - name: 'conversation', + name: 'main.conversation', locale: NS, children: { 'conversation.session': { kind: 'single', scope: 'session' }, @@ -230,8 +233,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), }, - selectWorkspace: async (workspaceId) => { - const nextId = await workspaceNavigation.connectWorkspace(workspaceId) + selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => { if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft @@ -251,8 +253,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { } } } - sessions.open(nextId) - }, + }), }), }, ConversationRoot) @@ -284,7 +285,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { store: conversationStore, inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionHeaderInjected => ({ hooks: { conversationViews }, - open: (id) => { sessions.open(id) }, + open: (id) => { workspaceNavigation.openSession(id) }, selectView: (view) => { activateView(sessionId, view) actions.setView(view) @@ -384,7 +385,12 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, }, InputBar) - slots.inject('conversation', function* () { + slots.inject('main', function* () { + yield slots.register({ + name: 'main', + key: 'conversation', + children: { 'main.conversation': { kind: 'single', scope: 'session-maybe' } }, + }, ConversationPanel) yield registerConversationRoot() yield registerConversationSession() yield registerConversationHeader() diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index 0b43e1fa55..0e61abe4b0 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -200,9 +200,10 @@ export interface CompactionSummaryNode { * Fallback for surface events this UI version does not know: the documented * default arm of `SessionEventMap`, which is merge-extensible, so the * projection's switch cannot end in `assertNever`. No event produces this node - * because `isAppendSurfaceEvent` admits only the three types in core's - * `SurfaceEventType`, and each has its own arm — and it exists so widening that - * set core-side degrades to a raw row instead of dropping the event silently. + * because `isAppendSurfaceEvent` admits only the four types in core's + * `SurfaceEventType`, and each has its own arm (`system/message` is claimed by + * a Chat Definition that renders no transcript row) — and it exists so widening + * that set core-side degrades to a raw row instead of dropping the event silently. */ export interface UnknownSurfaceNode { kind: 'unknown' diff --git a/packages/client/ui-conversation/src/client/contract/request-inspection.ts b/packages/client/ui-conversation/src/client/contract/request-inspection.ts index 486808745e..a59b803459 100644 --- a/packages/client/ui-conversation/src/client/contract/request-inspection.ts +++ b/packages/client/ui-conversation/src/client/contract/request-inspection.ts @@ -8,21 +8,53 @@ export type { AssistantProvenanceView, AssistantRequestConfig, } from './records.ts' -/** Complete model-visible request header in force for an ordinary generation. */ +/** + * Complete model-visible request state in force for an ordinary generation: + * the `request/header` config and tools plus the system prompt held by the + * current `system/message` surface node. + */ export interface ConversationPromptSnapshot { /** Provider/model and sampling configuration from the effective request header. */ config: AssistantRequestConfig - /** Rendered system prompt text; empty when the request had no system prompt. */ + /** + * Rendered text of the `system/message` surface node in force for the + * request; empty when the surface has no system prompt or the node lies + * outside the loaded history window. + */ system: string /** Complete tool catalog sent with the request, including tools that were never called. */ tools: readonly ToolSchema[] } -/** System/tool change introduced while preparing one ordinary request. */ -export interface RequestPromptChange { - /** Sequence of the request/header event that introduced this state. */ +/** Effective prompt or introduced system node, anchored at the event that establishes it. */ +export interface SystemPromptNode { + /** Sequence of the system event or replacement that establishes this prompt. */ seq: number - /** Unix epoch ms from the request/header event. */ + /** Unix epoch ms of that event. */ + time: number + /** Turn the loop committed the node in. */ + turn: number + /** Step the loop committed the node in. */ + step: number + /** Rendered system prompt text; empty records "no system prompt". */ + text: string + /** + * True for a prompt appended after an earlier loaded system node: an + * in-history update the model reads at this position, presented where it + * was committed rather than by the next request header. + */ + update: boolean +} + +/** System/tool change introduced while preparing one ordinary request, or by an in-history prompt update. */ +export interface RequestPromptChange { + /** + * Sequence of the event that introduced this state: the `system/message` + * node when it introduced, replaced, or updated the system prompt, otherwise the + * `request/header` event. + */ + seq: number + /** Unix epoch ms of that event. */ time: number /** How the model-visible prompt differs from the previous recorded state. */ kind: 'initial' | 'system' | 'tools' | 'system-and-tools' @@ -32,7 +64,7 @@ export interface RequestPromptChange { /** Canonical prompt snapshot and any model-visible change introduced by one request header. */ export interface RequestPromptInspection { - /** Complete prompt state recorded by the header. */ + /** Complete prompt state in force for the header's request. */ prompt: ConversationPromptSnapshot /** System/tool change relative to the preceding loaded header. */ change?: RequestPromptChange @@ -46,35 +78,41 @@ export interface RequestPromptInspection { export type RequestPromptInspector = ( previous: ConversationPromptSnapshot | undefined, event: SessionEvent<'request/header'>, + system: SystemPromptNode | undefined, ) => RequestPromptInspection /** - * Canonicalize one request header and classify its model-visible prompt change. + * Canonicalize one request header against the system node in force and + * classify the model-visible prompt change. * @param previous - Prompt from the preceding loaded request header, when available. * @param event - Durable full request header to inspect. + * @param system - Effective nonempty system prompt after loaded surface replacements; empty when removed. + * An in-history update already presented its text at its own position, so the header reports no system change for it. * @returns The canonical prompt and an initial/system/tool change when it can be established. */ export function inspectRequestPrompt( previous: ConversationPromptSnapshot | undefined, event: SessionEvent<'request/header'>, + system: SystemPromptNode | undefined, ): RequestPromptInspection { const header = event.data.header const rawTools: unknown = header.tools const prompt: ConversationPromptSnapshot = { config: header.config, - system: header.system ?? '', + system: system?.text ?? '', tools: Array.isArray(rawTools) ? rawTools as readonly ToolSchema[] : [], } if (previous === undefined && event.data.reason !== 'initial') return { prompt } - const systemChanged = previous !== undefined && previous.system !== prompt.system + const systemChanged = previous !== undefined && previous.system !== prompt.system && system?.update !== true const toolsChanged = previous !== undefined && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) if (previous !== undefined && !systemChanged && !toolsChanged) return { prompt } + const origin = system !== undefined && (previous === undefined || systemChanged) ? system : event return { prompt, change: { - seq: event.seq, - time: event.time, + seq: origin.seq, + time: origin.time, kind: previous === undefined ? 'initial' : systemChanged && toolsChanged diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index eb38a0f261..cf76e02b1b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -117,6 +117,8 @@ export type UseConversationViews = SnapshotSelectorHook declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Conversation shell beneath its root-scoped main-panel entry. */ + 'main.conversation': { kind: 'single'; scope: 'session-maybe' } /** Strict per-Session Conversation body. */ 'conversation.session': { kind: 'single'; scope: 'session' } /** Strict per-Session title, actions, and View navigation. */ @@ -364,7 +366,7 @@ export interface HeroBrandMarkOwnerProps { /** Full props of the resident optional-Session Conversation shell. */ export type ConversationSlotProps = - PropsRuntime<'conversation'> + PropsRuntime<'main.conversation'> & PropsRenderSlots< | 'conversation.session' | 'conversation.session.header' | 'conversation.composer' | 'conversation.composer.bar' diff --git a/packages/client/ui-conversation/src/client/contract/system-prompt.ts b/packages/client/ui-conversation/src/client/contract/system-prompt.ts new file mode 100644 index 0000000000..e61b35d81b --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/system-prompt.ts @@ -0,0 +1,85 @@ +/** Immutable system-only interpretation of the loaded Session surface. */ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { isSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type { SystemPromptNode } from './request-inspection.ts' + +interface PositionedSystem { + readonly position: number + readonly node: SystemPromptNode +} + +/** Prompt facts at one log prefix; earlier instances remain valid for historical cards. */ +export interface SystemPromptState { + /** Earliest relevant loaded event; older unindexed endpoints have unknown surface positions. */ + readonly firstSeq: number + /** Missing endpoint order makes the prompt unavailable until prepend replay supplies it. */ + readonly uncertain: boolean + /** Loaded system nodes in surface order, including empty dormant nodes. */ + readonly nodes: readonly PositionedSystem[] + /** Surviving replacement endpoints only, mapped to inherited surface positions. */ + readonly replacements: ReadonlyMap + /** Effective prompt and its change origin; empty text records removal, undefined means unavailable. */ + readonly effective: SystemPromptNode | undefined + /** System event at this position, if any; only this node may own an update card. */ + readonly introduced: SystemPromptNode | undefined +} + +/** Pure interpretation supplied to target-owned Definitions through uiConversation. */ +export type SystemPromptInspector = (previous: SystemPromptState | undefined, event: SessionEvent) => SystemPromptState + +/** + * Apply a system event or positional replacement without retaining ordinary messages. + * Replacement positions inherit their start endpoint, not their chronological seq. + * Unknown older endpoint order withholds the prompt until prepend replay resolves it. + * @param previous - Interpretation at the preceding relevant event in the loaded window. + * @param event - System message or surface replacement already admitted by Session. + * @returns Immutable surviving system facts and the effective nonempty prompt. + */ +export function inspectSystemPrompt(previous: SystemPromptState | undefined, event: SessionEvent): SystemPromptState { + const op = isSurfaceEvent(event) ? event.surfaceOp : undefined + const firstSeq = previous?.firstSeq ?? event.seq + let nodes = previous?.nodes ?? [] + let replacements = previous?.replacements ?? new Map() + const unknownEndpoint = (seq: number): boolean => seq < firstSeq && !replacements.has(seq) + const uncertain = previous?.uncertain === true || (op !== undefined && op !== 'append' + && (unknownEndpoint(op.startSeq) || unknownEndpoint(op.endSeq))) + if (uncertain) { + return { firstSeq, uncertain, nodes: [], replacements: new Map(), effective: undefined, introduced: undefined } + } + let position: number = event.seq + if (op !== undefined && op !== 'append') { + position = replacements.get(op.startSeq) ?? op.startSeq + const end = replacements.get(op.endSeq) ?? op.endSeq + nodes = nodes.filter(item => item.position < position || item.position > end) + const retained = new Map([...replacements].filter(([, value]) => value < position || value > end)) + retained.set(event.seq, position) + replacements = retained + } + const introduced: SystemPromptNode | undefined = event.type === 'system/message' + ? { + seq: event.seq, + time: event.time, + turn: event.data.turn, + step: event.data.step, + text: event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join(''), + update: op === 'append' && previous?.nodes.some(item => item.node.text !== '') === true, + } + : undefined + if (introduced !== undefined) { + nodes = [...nodes, { position, node: introduced }].sort((a, b) => a.position - b.position) + } + const surviving = nodes.findLast(item => item.node.text !== '')?.node + const effective = surviving === previous?.nodes.findLast(item => item.node.text !== '')?.node + ? previous?.effective + : introduced !== undefined && introduced === surviving + ? introduced + : { + seq: event.seq, + time: event.time, + turn: surviving?.turn ?? 0, + step: surviving?.step ?? 0, + text: surviving?.text ?? '', + update: false, + } + return { firstSeq, uncertain, nodes, replacements, effective, introduced } +} diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index a0bab0cb19..a6d453c65b 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -13,8 +13,11 @@ import type { ConversationViewSnapshotStore, } from '../contract/conversation.ts' import type { ConversationSnapshot } from '../contract/snapshot.ts' -import type { ConversationPromptSnapshot, RequestPromptInspection } from '../contract/request-inspection.ts' +import type { + ConversationPromptSnapshot, RequestPromptInspection, SystemPromptNode, +} from '../contract/request-inspection.ts' import { inspectRequestPrompt } from '../contract/request-inspection.ts' +import { inspectSystemPrompt, type SystemPromptState } from '../contract/system-prompt.ts' import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' @@ -270,20 +273,33 @@ export class UiConversation extends Service { } /** - * Canonicalize one `request/header` event against the previous prompt state. + * Interpret a system message or surface replacement for target-owned prompt Definitions. + * @param previous - System facts at the preceding relevant loaded event. + * @param event - Durable system message or positional replacement. + * @returns Immutable prompt interpretation at this event. + */ + inspectSystemPrompt(previous: SystemPromptState | undefined, event: SessionEvent): SystemPromptState { + return inspectSystemPrompt(previous, event) + } + + /** + * Canonicalize one `request/header` event against the previous prompt state + * and the `system/message` node in force. * * A pure interpretation shared by the Chat and Trajectory Definitions, exposed * as a service method because cross-plugin value imports are forbidden in * client bundles. * @param previous - prompt recorded by the preceding loaded header, if any. * @param event - the `request/header` session event to interpret. + * @param system - effective prompt after loaded surface replacements, if any. * @returns the canonical prompt snapshot and any model-visible change. */ inspectRequestPrompt( previous: ConversationPromptSnapshot | undefined, event: SessionEvent<'request/header'>, + system: SystemPromptNode | undefined, ): RequestPromptInspection { - return inspectRequestPrompt(previous, event) + return inspectRequestPrompt(previous, event, system) } private drop(record: BindingRecord, releaseScope: boolean): void { diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 3cbf676c88..52c801ae4d 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -32,8 +32,10 @@ export type { } from './contract/context-provenance.ts' export type { ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestPromptInspection, RequestPromptInspector, RequestView, + SystemPromptNode, } from './contract/request-inspection.ts' export { inspectRequestPrompt } from './contract/request-inspection.ts' +export type { SystemPromptState, SystemPromptInspector } from './contract/system-prompt.ts' export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts' export { ConversationNodeAssembler } from './conversation/assembler.ts' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx new file mode 100644 index 0000000000..8bd11aac33 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx @@ -0,0 +1,12 @@ +/** Root-scoped main occupant; Session binding belongs to its Conversation child. */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '../contract/slots.ts' + +/** + * Render the Conversation with optional current-Session binding. + * @param props - main-slot inputs and the declared Conversation renderer. + * @returns the Conversation subtree. + */ +export function ConversationPanel({ renderSlot }: PropsRuntime<'main'> & PropsRenderSlots<'main.conversation'>) { + return renderSlot('main.conversation', {}) +} diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 2dde9ce7b3..c6d6226bfe 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -49,7 +49,14 @@ async function bench() { } runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const connectWorkspace = vi.fn(async () => ROOT) - runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + const id = await connectWorkspace() + beforeOpen(id) + runtime.sessions.open(id) + }, + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, @@ -60,12 +67,12 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) const feature = await runtime.mount({ inject: [...inject], apply }) runtime.renderRoot() - const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') => + const entryOf = (key: 'main.conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') => runtime.slots.entries(key)[0]! const conversationApi = (id: SessionId) => { const entry = entryOf('conversation.session') @@ -77,7 +84,7 @@ async function bench() { return { instance, injected } } const residentApi = (id: SessionId | undefined) => { - const entry = entryOf('conversation') + const entry = entryOf('main.conversation') return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id) } const headerApi = (id: SessionId) => { diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index eccf4b87a0..3c59deb1c2 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -14,14 +14,20 @@ const SID = 'session-1' as SessionId async function bench(options: { declareConversation?: boolean } = {}) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: unknown, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) if (options.declareConversation !== false) { await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) } @@ -31,7 +37,7 @@ async function bench(options: { declareConversation?: boolean } = {}) { function entry( runtime: SlotTestRuntime, - key: 'conversation' | 'conversation.session' | 'conversation.session.header', + key: 'main.conversation' | 'conversation.session' | 'conversation.session.header', ) { return runtime.slots.entries(key)[0] as { store?: unknown } | undefined } @@ -39,14 +45,18 @@ function entry( describe('target-neutral Conversation apply wiring', () => { it('waits for the layout-owned conversation declaration before registering its subtree', async () => { const b = await bench({ declareConversation: false }) - expect(b.runtime.slots.entries('conversation')).toHaveLength(0) + expect(b.runtime.slots.entries('main')).toHaveLength(0) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0) await b.runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) - expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation']) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1) + expect(b.runtime.slots.spec('main.conversation')) + .toEqual({ kind: 'single', scope: 'session-maybe' }) expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1) expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1) expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1) @@ -65,7 +75,7 @@ describe('target-neutral Conversation apply wiring', () => { const b = await bench() const session = entry(b.runtime, 'conversation.session') const header = entry(b.runtime, 'conversation.session.header') - expect(entry(b.runtime, 'conversation')?.store).toBeUndefined() + expect(entry(b.runtime, 'main.conversation')?.store).toBeUndefined() expect(session?.store).toBeDefined() expect(header?.store).toBe(session?.store) expect(b.runtime.slots.spec('conversation.composer')) @@ -110,7 +120,9 @@ describe('target-neutral Conversation apply wiring', () => { await b.feature.dispose() expect(b.runtime.ctx.get('conversation')).toBeUndefined() expect(b.runtime.ctx.get('uiConversation')).toBeUndefined() - expect(b.runtime.slots.entries('conversation')).toHaveLength(0) + expect(b.runtime.slots.entries('main')).toHaveLength(0) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0) + expect(b.runtime.slots.spec('main.conversation')).toBeUndefined() expect(b.runtime.slots.spec('conversation.view')).toBeUndefined() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index fd99320d93..573a732780 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -12,6 +12,7 @@ import { import { InputHub } from '../src/client/input/hub.ts' import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). @@ -40,13 +41,13 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) -type AppRootProps = PropsRenderSlots<'conversation'> +type AppRootProps = PropsRenderSlots<'main'> function AppRoot({ renderSlot }: AppRootProps) { - return <>{renderSlot('conversation', {})} + return <>{renderSlot('main', {}, { entryKey: 'conversation' })} } const LAYOUT_CHILDREN = { - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, } as const function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { @@ -60,7 +61,13 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -82,7 +89,13 @@ async function bench(opts?: { blank?: boolean }) { describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -109,7 +122,13 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -174,7 +193,13 @@ describe('resident composer', () => { describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 15ad36aec2..1b12d7f1a0 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -1291,7 +1291,7 @@ describe('ConversationNodeAssembler', () => { it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => { const definition: ConversationNodeDefinition = { kind: 'location-probe', - match: event => (event.type as string) === 'tool/code-dispatch-start' + match: event => (event.type as string) === 'tool/ptc-dispatch-start' ? { id: String(event.seq), role: 'start' } : null, start: () => null, @@ -1310,11 +1310,11 @@ describe('ConversationNodeAssembler', () => { ) assembler.replaceWindow([ input(at(SessionSeq(10), 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), - input(at(SessionSeq(11), 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })), + input(at(SessionSeq(11), 'tool/ptc-dispatch-start', { rootCallId: 'root', subCallId: 'a' })), ], true) assembler.flush() - assembler.append(input(at(SessionSeq(12), 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' }))) + assembler.append(input(at(SessionSeq(12), 'tool/ptc-dispatch-start', { rootCallId: 'root', subCallId: 'b' }))) assembler.flush() expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 0cba7c36b5..66011fc2b0 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -13,7 +13,7 @@ import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy. import { en } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] afterEach(() => { cleanup() @@ -40,6 +40,7 @@ function mount() { const policy = new ComposerSubmissionPolicy() const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) }) const props: EnterBehaviorRowProps = { + usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), useSessionPendingInteraction: noPendingInteraction(), useResource, diff --git a/packages/client/ui-conversation/tests/history-transport.perf.client.ts b/packages/client/ui-conversation/tests/history-transport.perf.client.ts index ab4f66cabc..d1b905c10f 100644 --- a/packages/client/ui-conversation/tests/history-transport.perf.client.ts +++ b/packages/client/ui-conversation/tests/history-transport.perf.client.ts @@ -228,7 +228,7 @@ function appendSeparator(events: SessionEvent[], separator: number): void { time: TIME_ZERO + seq, data: { separator }, ignorable: true, - } as SessionEvent) + } as unknown as SessionEvent) } function fragment(run: number, index: number): string { diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 051aa295e3..81d25084a9 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -33,7 +33,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] afterEach(cleanup) @@ -164,6 +164,7 @@ function bench(over?: BenchOptions) { return null }) as never const props: InputBarProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index c333d25ada..de0e3aa196 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -26,7 +26,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). @@ -48,6 +48,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled removed: over?.disabled ?? false, }) const props: InputBarProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 9e41f82eb7..4571ebce58 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -33,7 +33,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). @@ -137,6 +137,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const wiring = shell const sessionStore = createSnapshotStore(sessionSnapshot(sessionId)) const barProps: InputBarProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId, SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(sessionStore), diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index b64e57aa54..0f5942a6bc 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -24,7 +24,7 @@ import { zh } from '../src/client/locales.ts' import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] afterEach(cleanup) @@ -73,11 +73,13 @@ function liveSession(initial: SessionSnapshot) { const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) function kitFor(snapshot: SessionSnapshot, injected: Partial = {}) { return { sessionId: SID, t, + usePanelInfo, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, useResource, useSessionPendingInteraction: bindSnapshotSelector( @@ -177,6 +179,42 @@ describe('QueueDock', () => { expect((view.getByRole('textbox') as HTMLInputElement).value).toBe('等待上传') }) + it('loads the durable thumbnail after replacing a local image echo', async () => { + const pending: SessionSnapshot = { + ...snapshotWith([]), + pendingSubmissions: [{ + requestId: 'req-image' as never, placement: 'queued', time: 1, + text: 'queued image', + attachments: [{ + type: 'image', value: { previewUrl: 'blob:local-preview', name: 'queue.png' }, + }], + }], + } + const image = Promise.withResolvers() + const loadImage = vi.fn(() => image.promise) + const source = liveSession(pending) + const view = render() + expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:local-preview') + expect(loadImage).not.toHaveBeenCalled() + + act(() => { + source.push({ + ...pending, + queue: [{ ...imageRow('accepted-image', 'durable-image', 'queued image'), rpcId: 'req-image' as never }], + }) + }) + expect(view.container.querySelector('[data-submission-echo]')).toBeNull() + expect(view.getByText('queued image')).toBeTruthy() + expect(view.getByRole('button', { name: '删除排队消息' })).toHaveProperty('disabled', false) + expect(view.queryByRole('img', { name: '排队消息图片' })).toBeNull() + expect(loadImage).toHaveBeenCalledOnce() + + await act(async () => { image.resolve('blob:durable-image'); await image.promise }) + const thumbnail = view.getByRole('img', { name: '排队消息图片' }) + expect(thumbnail.getAttribute('src')).toBe('blob:durable-image') + expect(thumbnail.closest('li')?.hasAttribute('data-submission-echo')).toBe(false) + }) + it('keeps sending status visible while a queue containing local submissions is collapsed', () => { const pending: SessionSnapshot = { ...snapshotWith([row('accepted', '已排队')]), diff --git a/packages/client/ui-conversation/tests/request-inspection.client.spec.ts b/packages/client/ui-conversation/tests/request-inspection.client.spec.ts index 5fbf5cd271..bb9d794e68 100644 --- a/packages/client/ui-conversation/tests/request-inspection.client.spec.ts +++ b/packages/client/ui-conversation/tests/request-inspection.client.spec.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from 'vitest' import { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SystemPromptNode } from '../src/client/contract/request-inspection.ts' import { inspectRequestPrompt } from '../src/client/contract/request-inspection.ts' const CONFIG = { provider: 'test', model: 'test' } +const READ_TOOL = { name: 'read', description: 'Read a file', parameters: { type: 'object' } } +const WRITE_TOOL = { name: 'write', description: 'Write', parameters: { type: 'object' } } function header( seq: SessionSeq, @@ -18,13 +21,16 @@ function header( } } +function systemNode(seq: number, text: string, update = false): SystemPromptNode { + return { seq, time: 1_700_000_000_000 + seq, turn: 1, step: seq, text, update } +} + describe('inspectRequestPrompt', () => { - it('classifies the first complete header as the initial prompt', () => { - expect(inspectRequestPrompt(undefined, header(SessionSeq(1), 'initial', { + it('classifies the first complete header as the initial prompt anchored at its system node', () => { + expect(inspectRequestPrompt(undefined, header(SessionSeq(3), 'initial', { config: CONFIG, - system: '# System\n\nFollow instructions.', - tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }], - }))).toMatchObject({ + tools: [READ_TOOL], + }), systemNode(1, '# System\n\nFollow instructions.'))).toMatchObject({ prompt: { config: CONFIG, system: '# System\n\nFollow instructions.', @@ -34,53 +40,91 @@ describe('inspectRequestPrompt', () => { }) }) + it('anchors an initial header without a system node at the header itself', () => { + expect(inspectRequestPrompt(undefined, header(SessionSeq(2), 'initial', { + config: CONFIG, + }), undefined)).toEqual({ + prompt: { config: CONFIG, system: '', tools: [] }, + change: { seq: 2, time: 1_700_000_000_002, kind: 'initial' }, + }) + }) + it('suppresses a resume header when the earlier prompt is outside the loaded window', () => { expect(inspectRequestPrompt(undefined, header(SessionSeq(2), 'resume', { config: CONFIG, - system: 'same prompt', - }))).toEqual({ + }), systemNode(1, 'same prompt'))).toEqual({ prompt: { config: CONFIG, system: 'same prompt', tools: [] }, }) }) it('classifies system, tool, and combined changes against the previous prompt', () => { - const initial = inspectRequestPrompt(undefined, header(SessionSeq(1), 'initial', { + const initial = inspectRequestPrompt(undefined, header(SessionSeq(2), 'initial', { config: CONFIG, - system: 'first', - tools: [{ name: 'read', description: 'Read', parameters: { type: 'object' } }], - })).prompt - const system = inspectRequestPrompt(initial, header(SessionSeq(2), 'change', { + tools: [READ_TOOL], + }), systemNode(1, 'first')).prompt + const system = inspectRequestPrompt(initial, header(SessionSeq(4), 'series', { config: CONFIG, - system: 'second', tools: [...initial.tools], - })) - const tools = inspectRequestPrompt(system.prompt, header(SessionSeq(3), 'change', { + }), systemNode(3, 'second')) + const tools = inspectRequestPrompt(system.prompt, header(SessionSeq(5), 'change', { config: CONFIG, - system: 'second', - tools: [{ name: 'write', description: 'Write', parameters: { type: 'object' } }], - })) - const combined = inspectRequestPrompt(tools.prompt, header(SessionSeq(4), 'change', { + tools: [WRITE_TOOL], + }), systemNode(3, 'second')) + const combined = inspectRequestPrompt(tools.prompt, header(SessionSeq(7), 'change', { config: CONFIG, - system: 'third', tools: [], - })) + }), systemNode(6, 'third')) - expect(system.change?.kind).toBe('system') - expect(tools.change?.kind).toBe('tools') - expect(combined.change?.kind).toBe('system-and-tools') - expect(combined.change?.previous).toBe(tools.prompt) + expect(system.change).toEqual({ seq: 3, time: 1_700_000_000_003, kind: 'system', previous: initial }) + expect(tools.change).toEqual({ seq: 5, time: 1_700_000_000_005, kind: 'tools', previous: system.prompt }) + expect(combined.change).toEqual({ + seq: 6, time: 1_700_000_000_006, kind: 'system-and-tools', previous: tools.prompt, + }) }) - it('omits a change when the prompt and tools are unchanged', () => { - const previous = inspectRequestPrompt(undefined, header(SessionSeq(1), 'initial', { + it('reports an emptied system prompt as a system change anchored at the replacing node', () => { + const initial = inspectRequestPrompt(undefined, header(SessionSeq(2), 'initial', { config: CONFIG, - system: 'same', - })).prompt + }), systemNode(1, 'first')).prompt - expect(inspectRequestPrompt(previous, header(SessionSeq(2), 'resume', { + expect(inspectRequestPrompt(initial, header(SessionSeq(4), 'series', { + config: CONFIG, + }), systemNode(3, ''))).toEqual({ + prompt: { config: CONFIG, system: '', tools: [] }, + change: { seq: 3, time: 1_700_000_000_003, kind: 'system', previous: initial }, + }) + }) + + it('reports no system change for an in-history update the model already read at its own position', () => { + const initial = inspectRequestPrompt(undefined, header(SessionSeq(2), 'initial', { + config: CONFIG, + tools: [READ_TOOL], + }), systemNode(1, 'first')).prompt + + // A later series header carries the updated text without a system change… + const series = inspectRequestPrompt(initial, header(SessionSeq(4), 'series', { + config: CONFIG, + tools: [READ_TOOL], + }), systemNode(3, 'updated', true)) + expect(series).toEqual({ prompt: { config: CONFIG, system: 'updated', tools: [READ_TOOL] } }) + + // …and a tools change alongside the update reports only the tools, anchored at the header. + expect(inspectRequestPrompt(initial, header(SessionSeq(5), 'change', { + config: CONFIG, + tools: [WRITE_TOOL], + }), systemNode(3, 'updated', true)).change).toEqual({ + seq: 5, time: 1_700_000_000_005, kind: 'tools', previous: initial, + }) + }) + + it('omits a change when the system node and tools are unchanged', () => { + const previous = inspectRequestPrompt(undefined, header(SessionSeq(2), 'initial', { + config: CONFIG, + }), systemNode(1, 'same')).prompt + + expect(inspectRequestPrompt(previous, header(SessionSeq(3), 'resume', { config: { ...CONFIG, maxTokens: 1_024 }, - system: 'same', - }))).toEqual({ + }), systemNode(1, 'same'))).toEqual({ prompt: { config: { ...CONFIG, maxTokens: 1_024 }, system: 'same', tools: [] }, }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index f1735ce415..f515f20ce1 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -34,7 +34,7 @@ import type { import type { ViewTab } from '../src/client/contract/views.ts' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). @@ -199,6 +199,7 @@ function mount( useChat={useChat} useTrajectory={useTrajectory} useSessions={props.useSessions} + usePanelInfo={props.usePanelInfo} useResource={useResource} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} @@ -225,6 +226,7 @@ function mount( useChat={useChat} useTrajectory={useTrajectory} useSessions={props.useSessions} + usePanelInfo={props.usePanelInfo} useResource={useResource} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} @@ -251,6 +253,7 @@ function mount( useSession={useSession} useConversation={useConversation} useSessions={props.useSessions} + usePanelInfo={props.usePanelInfo} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} @@ -295,6 +298,7 @@ function mount( : (opts?.fallback ?? null) )) as ConversationRootProps['renderSlotChain'] const props: ConversationRootProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, SessionProvider: ({ children }) => children, useSession, diff --git a/packages/client/ui-conversation/tests/system-prompt.client.spec.ts b/packages/client/ui-conversation/tests/system-prompt.client.spec.ts new file mode 100644 index 0000000000..4604de6f4b --- /dev/null +++ b/packages/client/ui-conversation/tests/system-prompt.client.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { inspectSystemPrompt, type SystemPromptState } from '../src/client/contract/system-prompt.ts' + +function system(seq: number, text: string, replaces?: number): SessionEvent { + return { + seq, time: seq, type: 'system/message', + data: { turn: 1, step: seq, message: { role: 'system', content: [{ type: 'text', text }] } }, + surfaceOp: replaces === undefined ? 'append' : { op: 'replace', startSeq: replaces, endSeq: replaces }, + } as SessionEvent +} + +function replace(seq: number, start: number, end: number): SessionEvent { + return { seq, time: seq, type: 'user/message', surfaceOp: { op: 'replace', startSeq: start, endSeq: end } } as SessionEvent +} + +function fold(events: readonly SessionEvent[]): SystemPromptState | undefined { + return events.reduce((previous, event) => inspectSystemPrompt(previous, event), undefined) +} + +describe('loaded system surface interpretation', () => { + it('withholds unknown replacement order until the missing prefix is loaded', () => { + const prefix = [system(1, 'A'), system(3, 'B'), system(5, 'A2', 1)] + const tail = [system(6, 'C', 3), system(7, 'D', 5)] + expect(fold(tail)?.effective).toBeUndefined() + expect(fold([...prefix, ...tail])?.effective?.text).toBe('C') + }) + + it('restores A after compaction shadows B while preserving historical state', () => { + const a = fold([system(1, 'A')]) + const b = inspectSystemPrompt(a, system(4, 'B')) + const restored = inspectSystemPrompt(b, replace(7, 3, 5)) + expect(restored.effective).toMatchObject({ text: 'A', seq: 7, update: false }) + expect(a?.effective?.text).toBe('A') + expect(b.effective?.text).toBe('B') + expect(inspectSystemPrompt(restored, replace(8, 7, 7)).effective).toBe(restored.effective) + }) + + it('uses surface order across chained replacements with reversed chronological endpoints', () => { + const state = fold([ + system(1, 'A'), system(4, 'B'), system(6, 'C'), + system(8, 'new A', 1), replace(9, 3, 5), replace(10, 9, 6), + ]) + expect(state?.effective).toMatchObject({ text: 'new A', seq: 10 }) + expect(state?.nodes.map(item => item.node.text)).toEqual(['new A']) + }) + + it('keeps later nonempty nodes authoritative over head rewrites and ignores empty tails', () => { + const b = fold([system(1, 'A'), system(3, 'B'), system(5, 'head', 1)]) + expect(b?.effective?.text).toBe('B') + const head = inspectSystemPrompt(b, system(6, '', 3)) + expect(head.effective).toMatchObject({ text: 'head', seq: 6, update: false }) + expect(inspectSystemPrompt(head, system(7, '', 5)).effective?.text).toBe('') + }) + + it('does not invent an unloaded head after a cross-window replacement', () => { + const state = fold([system(8, 'B'), replace(12, 5, 9)]) + expect(state?.nodes).toEqual([]) + expect(state?.effective).toBeUndefined() + expect(fold([replace(12, 5, 9)])?.effective).toBeUndefined() + }) + + it('drops shadowed replacement endpoints without mutating historical prefixes', () => { + const first = fold([system(1, 'A'), system(2, 'B', 1)]) + let state = first + for (let seq = 3; seq < 100; seq++) state = inspectSystemPrompt(state, system(seq, 'C', seq - 1)) + expect(state?.replacements.size).toBe(1) + expect([...state!.replacements]).toEqual([[99, 1]]) + expect([...first!.replacements]).toEqual([[2, 1]]) + expect(first?.effective?.text).toBe('B') + }) + + it('extracts text blocks once and treats an empty initial node as no prompt', () => { + const event = system(1, 'A') + if (event.type !== 'system/message') throw new Error('expected system event') + const multiBlock = { ...event, data: { ...event.data, message: { + ...event.data.message, content: [{ type: 'text' as const, text: 'A' }, { type: 'text' as const, text: 'B' }], + } } } + expect(inspectSystemPrompt(undefined, multiBlock).effective?.text).toBe('AB') + expect(inspectSystemPrompt(undefined, system(2, '')).effective).toBeUndefined() + }) +}) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 01417ca259..9cc543ee66 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: bfd4c40a952122db25172bebb077d12800b49308 -README.zh.md: 23c983f938d2d77fa5f33e8dcd2526282268b835 +README.md: ae42abb5a69d29332bd7cfd6d2d2a1191381e79a +README.zh.md: 664dfa3cd2e0d526b43464a80290604a8ecf958c diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index bfd4c40a95..ae42abb5a6 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -27,6 +27,13 @@ This package renders the deliverables row a finished turn ends with — the file Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar as a text-preview tab, with relative paths resolved against the session cwd. The row offers no folder action: the Sidebar has no directory form, so an omitted-file remainder is a label only. + +### Explicit deliveries + +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. + +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. + ### The row The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. @@ -43,7 +50,9 @@ The closing prose carries the same vocabulary: an inline-code token resolves by

Implementation internals — click to expand -The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers `ProducedFiles` into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. +The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. + +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
@@ -72,7 +81,7 @@ One fixed paragraph instructs the model to name primary files from successful cr #### Token effect -One fixed prompt paragraph whenever this package is loaded; no tool schema, tool result, or per-Turn context is added. +One fixed prompt paragraph whenever this package is loaded. The [present tool](../../fs/tool-present/README.md#model-experience) owns the delivery schema and result text. #### KV Cache effect @@ -86,7 +95,8 @@ The section is static at first-party order 9000 for the lifetime of the package These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog. - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. -- **Files created indirectly by terminal commands remain outside the matching vocabulary** — naming such a file in inline code does not make it clickable unless a successful mutation location also records that path. +- **Terminal-created files require explicit delivery** — call `present` to declare them for native opening. +- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403. - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. @@ -99,4 +109,4 @@ None.
-**Runtime invariant:** No companion is published. The prompt section, slot, dictionary, event definition, and optional service registrations are effect-owned with disposal proven by their plugin specs; this package owns no mutable state. +**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 23c983f938..664dfa3cd2 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -27,6 +27,13 @@ kind: "package-reference" 与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略文件的余数只是一个标签才会打开会话工作区。 + +### 显式交付 + +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 + +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 + ### 该行 该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 @@ -43,7 +50,9 @@ kind: "package-reference"
实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把 `ProducedFiles` 注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 +Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 + +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
@@ -72,7 +81,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, #### Token 影响 -加载本包时增加一段固定提示词;不增加工具 schema、工具结果或按轮次变化的上下文。 +加载本包时增加一段固定提示词。[present 工具](../../fs/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。 #### KV Cache 影响 @@ -86,8 +95,9 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 -- **终端命令间接创建的文件仍不在匹配词表内**——除非某个成功修改位置也记录了该路径,否则在行内代码中点名这类文件不会使其可点击。 -- **原生文件夹交接以 Host 桌面为目标**——经非 loopback authority 访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端 Host 看似 loopback 本地,部署必须为 Session Controller 设置 `nativeOpen: false`。 +- **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。 +- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403。 +- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。 ### 开发备注 @@ -99,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
-**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、event definition 与可选 service 注册都归 effect 所有,释放由插件测试证明;本包不持有可变状态。 +**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 5cb017c4d2..67970786a0 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -33,7 +33,8 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-renderer" + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-tool" ], "platform": "web" } @@ -61,7 +62,12 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^" + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", + "@deepseek-ai/dsh-tool-present": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css new file mode 100644 index 0000000000..97befd2946 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -0,0 +1,14 @@ +/** Immutable file deliveries at the end of a turn. */ +.root { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin-top: 12px; } +.label { font-size: 12px; color: var(--dsw-alias-label-secondary); } +.presented { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; } +.file { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 14px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); text-decoration: none; font: inherit; text-align: left; cursor: pointer; } +.file:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l3); } +.file:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; } +.fileIcon { flex: 0 0 auto; width: 24px; height: 24px; } +.details { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; } +.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } +.metadata { color: var(--dsw-alias-label-tertiary); font-size: 11px; } +.description { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; } +.open { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; } +.file:disabled { cursor: wait; opacity: 0.65; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx new file mode 100644 index 0000000000..2faf259ad4 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -0,0 +1,64 @@ +/** Existing changed-file chips and explicitly declared files for a closing turn. */ +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' +import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { PresentedOpenController } from './present-open.ts' +import { ProducedFiles } from './ProducedFiles.tsx' +import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import { presentedFileUrl } from '../presented.ts' +import css from './Deliverables.module.css' + +interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] } + +/** Native-open callbacks and shared gesture status supplied by the plugin. */ +export interface DeliverablesInjected { + hooks: { presentedOpen: ObservableSnapshot> } + openPresented: PresentedOpenController['open'] +} + +/** + * Claim turns containing modified paths or declared files. + * @param owner - closing turn. + * @returns matched files, or null for an empty turn. + */ +export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch | null { + const produced = selectProducedFiles(owner) ?? [] + const presented = presentedForClosing(owner) + return produced.length + presented.length === 0 ? null : { produced, presented } +} + +/** + * Render workspace file actions and default-application buttons for declared files. + * @param props - matched files, workspace opener, and localized copy. + * @returns the closing turn's file rows. + */ +export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen }: Pick & { + matched: DeliverablesMatch +} & PropsLocale & Pick & InjectFace) { + const states = usePresentedOpen(value => value) + return <> + {matched.produced.length > 0 && } + {matched.presented.length > 0 &&
+ {t('presented.label')} +
+ {matched.presented.map((file) => { + const phase = states[presentedFileUrl(sessionId, file.seq, file.index)] + return })} +
+
} + +} diff --git a/packages/client/ui-deliverables/src/client/PresentRow.module.css b/packages/client/ui-deliverables/src/client/PresentRow.module.css new file mode 100644 index 0000000000..b97ff7de02 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/PresentRow.module.css @@ -0,0 +1,6 @@ +/** Present status remains readable beside long workspace paths. */ +.summary { margin-left: 8px; display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; } +.summary > :first-child { flex-shrink: 0; } +.paths { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.output { margin: 8px 0; padding: 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; } +.inspect { align-self: flex-start; border: none; padding: 4px 0; background: transparent; color: var(--dsw-alias-link); font: inherit; font-size: 12px; cursor: pointer; } diff --git a/packages/client/ui-deliverables/src/client/PresentRow.tsx b/packages/client/ui-deliverables/src/client/PresentRow.tsx new file mode 100644 index 0000000000..845d2b4dcc --- /dev/null +++ b/packages/client/ui-deliverables/src/client/PresentRow.tsx @@ -0,0 +1,45 @@ +/** Present call status and expandable durable result text. */ +import { useState } from 'react' +import { DisclosureRow, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { NS } from './locales.ts' +import css from './PresentRow.module.css' + +type PresentRowProps = ToolCallViewProps & PropsLocale + +/** Raw arguments can be partial while a call is streaming. */ +function fileNames(raw: string): string { + let args: unknown + try { args = JSON.parse(raw) } + catch { return raw } // Truncated tool JSON remains visible until the call completes. + if (typeof args !== 'object' || args === null || !('files' in args) || !Array.isArray(args.files)) return raw + return args.files.flatMap((file: unknown) => + typeof file === 'object' && file !== null && 'path' in file && typeof file.path === 'string' + ? [file.path] : [], + ).join(', ') +} + +/** + * Render a present call using its recorded arguments and result. + * @param props - tool call and localized status copy. + * @returns a status row with a result disclosure. + */ +export function PresentRow({ block, inspect, t }: PresentRowProps) { + const settled = 'kind' in block + const state = !settled ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' + const args = (settled ? block.call?.argsRaw : block.argsRaw) ?? '' + const output = settled ? block.content.map(item => item.type === 'text' ? item.text : JSON.stringify(item)).join('\n') : '' + const details = output || (settled && block.error ? `${block.error.name}: ${block.error.code}` : '') + const [expanded, setExpanded] = useState(false) + return
+ } + open={expanded && details !== ''} expandable={details !== ''} expandOnRowClick keepContentWhenOpen + onToggle={() => { setExpanded(value => !value) }} + collapsedContent={{t(`row.${state}`)}{fileNames(args)}}> +
{details}
+ {inspect && } +
+
+} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 01877b7b57..09156448b4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -13,10 +13,12 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import { ProducedFiles } from './ProducedFiles.tsx' +import { PresentedOpenController } from './present-open.ts' +import { PresentRow } from './PresentRow.tsx' +import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' import { - deliverablesDefinition, producedFileMentions, selectProducedFiles, + deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles, } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -37,26 +39,41 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + const opener = new PresentedOpenController() + ctx.effect(() => () => opener.dispose()) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( 'conversation.chat.turnTail', () => ctx.slots.register({ name: 'conversation.chat.turnTail', - select: selectProducedFiles, + select: selectDeliverables, locale: NS, - }, ProducedFiles), + inject: (): DeliverablesInjected => ({ + hooks: { presentedOpen: opener.state }, + openPresented: (sessionId, seq, index) => opener.open(sessionId, seq, index), + }), + }, Deliverables), ) + ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( + { name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow, + )) // The prose side of the same vocabulary: the chat view reaches this face // via ctx.get, so its absence — this plugin composed out — is the off state. const t = ctx.locale.bind(NS) const mentions: ChatFileMentions = { - forClosing(owner) { + forClosing(owner, sessionId) { // Same claim test the turn-tail chain entry runs: no produced files, // no vocabulary — the two surfaces agree by construction. const paths = selectProducedFiles(owner) - if (paths === null) return undefined - return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path })) + const presented = presentedForClosing(owner) + if (paths === null && presented.length === 0) return undefined + const deliveries = new Map(presented.map(file => [file.path, file])) + return producedFileMentions([...new Set([...paths ?? [], ...deliveries.keys()])], (path) => { + const file = deliveries.get(path) + if (file === undefined) owner.openFile(path) + else void opener.open(sessionId, file.seq, file.index) + }, path => t(deliveries.has(path) ? 'presented.open' : 'produced.open', { name: path })) }, } ctx.provide('chatFileMentions', mentions) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 158b659f59..d9b468e15c 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,6 +5,19 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'presented.label': '交付文件', + 'presented.action': '打开', + 'presented.opening': '正在打开…', + 'presented.opened': '已在默认程序中打开', + 'presented.error': '打开失败,点击重试', + 'presented.file': '文件', + 'row.title': '交付文件', + 'row.running': '正在交付', + 'row.ok': '已交付', + 'row.error': '交付失败', + 'row.stopped': '已中断', + 'row.inspect': '查看调用', + 'presented.open': '在默认程序中打开 {name}', 'produced.label': '产物', 'produced.moreOne': '+ 1 个文件', 'produced.more': '+ {count} 个文件', @@ -13,6 +26,19 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { + 'presented.label': 'Deliverables', + 'presented.action': 'Open', + 'presented.opening': 'Opening…', + 'presented.opened': 'Opened in default app', + 'presented.error': 'Could not open. Click to retry.', + 'presented.file': 'File', + 'row.title': 'Present files', + 'row.running': 'Delivering', + 'row.ok': 'Delivered', + 'row.error': 'Delivery failed', + 'row.stopped': 'Interrupted', + 'row.inspect': 'Inspect call', + 'presented.open': 'Open {name} in default app', 'produced.label': 'Produced', 'produced.moreOne': '+ 1 file', 'produced.more': '+ {count} files', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts new file mode 100644 index 0000000000..5b49941a57 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -0,0 +1,54 @@ +/** Shared native-open status for delivery cards and closing-message file mentions. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { presentedFileUrl } from '../presented.ts' + +/** State of the latest explicit open gesture for one saved file. */ +export type PresentedOpenPhase = 'opening' | 'opened' | 'error' + +/** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ +export class PresentedOpenController { + /** File action URLs key the state across Sessions, turns, and both clickable surfaces. */ + readonly state = createSnapshotStore>({}) + private readonly lifetime = new AbortController() + private readonly pending = new Set>() + + /** + * Open a declared workspace file once while a request for the same coordinates is pending. + * Failures remain visible on the card and a later gesture retries them. + * @param sessionId - viewed Session, including a fork's own identity. + * @param seq - durable delivery event sequence. + * @param index - original file index within that event. + * @returns after the Host acknowledges opening or the error state is published. + */ + async open(sessionId: SessionId, seq: number, index: number): Promise { + const url = presentedFileUrl(sessionId, seq, index) + if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return + this.state.update((state) => { state[url] = 'opening' }) + const task = this.request(url) + this.pending.add(task) + try { + await task + } finally { + this.pending.delete(task) + } + } + + /** Cancel outstanding requests and wait until no request can publish state. */ + async dispose(): Promise { + this.lifetime.abort() + await Promise.all(this.pending) + } + + private async request(url: string): Promise { + let phase: PresentedOpenPhase = 'opened' + try { + const response = await fetch(url, { method: 'POST', signal: this.lifetime.signal }) + if (!response.ok) phase = 'error' + } catch { + // Transport failures share the retryable card state with Host open failures. + phase = 'error' + } + if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) + } +} diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 90e3248516..3a8025d68d 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -7,6 +7,14 @@ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types' +import { basename, isPresentedData, isPresentedFile } from '../presented.ts' + +/** A declared file with its authorized open coordinates. */ +export interface PresentedPath extends PresentedFile { + readonly seq: number + readonly index: number +} interface ProducedPath { readonly seq: number @@ -16,6 +24,7 @@ interface ProducedPath { /** Immutable produced-file facts published against one Turn. */ export interface DeliverablesTurnData { readonly produced: readonly ProducedPath[] + readonly presented?: readonly PresentedPath[] } declare module '@deepseek-ai/dsh-client-ui-conversation/client' { @@ -150,6 +159,7 @@ export const deliverablesDefinition: ConversationNodeDefinition { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' } + if (event.type === 'deliverables/presented') return isPresentedData(event.data) ? { id: String(event.data.turn), role: 'update' } : null if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { return { id: String(event.data.turn), role: 'update' } } @@ -160,6 +170,17 @@ export const deliverablesDefinition: ConversationNodeDefinition { + if (match.event.type === 'deliverables/presented') { + const { files } = match.event.data + const seq = match.event.seq + const presented: PresentedPath[] = [] + for (let index = 0; index < files.length; index += 1) { + const file = files[index] + if (isPresentedFile(file)) presented.push({ ...file, seq, index }) + } + if (presented.length === 0) return context.state + return { ...context.state, presented: [...context.state.presented ?? [], ...presented] } + } if (match.event.type === 'tool/call') { const calls = new Map(context.state.calls) calls.set( @@ -182,26 +203,32 @@ export const deliverablesDefinition: ConversationNodeDefinition() + for (const file of owner.turn.data.get('deliverables')?.presented ?? []) { + if (file.seq < owner.seq) files.set(file.path, file) + } + return [...files.values()] } +export { basename } from '../presented.ts' + /** * File-mention vocabulary over one turn's produced paths, for the closing * message's prose: an inline-code token opens the file it names. A token diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 00e1adac9c..4239299e63 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,15 +1,17 @@ /** * Deliverables plugin, node half. Registers the response-format guidance that - * lets the browser half recognize final-response file references. The browser + * lets the browser half recognize final-response file references and serves + * authenticated native opens of workspace files. The browser * half ships via exports["./client"], discovered through the package.json * dsh.client declaration. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-system-prompt' +import { registerPresentOpen } from './present-open.ts' -/** Services required for the model guidance paired with the browser renderer. */ -export const inject = ['systemPrompt'] +/** Services required for file-reference guidance and authenticated native opens of workspace files. */ +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' @@ -20,6 +22,7 @@ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, men * @param ctx - host context carrying the system-prompt registry. */ export function apply(ctx: Context): void { + registerPresentOpen(ctx) ctx.systemPrompt.section({ name: 'ui:deliverable-file-references', order: ctx.systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES'), diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts new file mode 100644 index 0000000000..beac77657a --- /dev/null +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -0,0 +1,71 @@ +/** Open declared source files inside the viewed Session's workspace. */ +import { realpath, stat } from 'node:fs/promises' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-client-connection' +import type {} from '@deepseek-ai/dsh-session-query' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH } from './presented.ts' + +/** + * Register native opening inside Connection's authentication fence. + * @param ctx - Session lookup, native opener, and route lifetime. + */ +export function registerPresentOpen(ctx: Context): void { + const lifetime = new AbortController() + const pending = new Set>() + ctx.effect(() => async () => { + lifetime.abort() + await Promise.allSettled(pending) + }) + ctx.connection.fetch.register({ + path: PRESENT_OPEN_PATH, + methods: ['POST'], + requestBody: 'buffered', + fetch: (request) => { + const task = handlePresentOpen(ctx, new Request(request, { + signal: AbortSignal.any([request.signal, lifetime.signal]), + })) + pending.add(task) + void task.then(() => { pending.delete(task) }, () => { pending.delete(task) }) + return task + }, + }) +} + +async function handlePresentOpen(ctx: Context, request: Request): Promise { + const query = new URL(request.url).searchParams + const id = query.get('sessionId') + const seq = query.get('seq') + const index = query.get('index') + if (!id || seq === null || index === null || !/^\d+$/.test(seq) || !/^\d+$/.test(index) + || !Number.isSafeInteger(Number(seq)) || !Number.isSafeInteger(Number(index))) { + return new Response('Invalid Presented file coordinates.', { status: 400 }) + } + try { + request.signal.throwIfAborted() + const { session, target } = await ctx.sessionQuery.readEvent({ + sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0, + }, request.signal) + const file = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[Number(index)] : undefined + if (!isPresentedFile(file)) return new Response('Presented file not found in this Session result.', { status: 404 }) + if (session.cwd === undefined) return new Response('Session workspace unavailable.', { status: 404 }) + const root = await realpath(session.cwd) + const path = await realpath(resolve(root, file.path)) + const within = relative(root, path) + if (isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`)) { + return new Response('Presented file is outside the workspace.', { status: 403 }) + } + if (!(await stat(path)).isFile()) return new Response('Presented path is not a file.', { status: 404 }) + request.signal.throwIfAborted() + await ctx.sessionController.openWorkspacePath({ path }, request.signal) + return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } }) + } catch (error: unknown) { + request.signal.throwIfAborted() + const missing = error instanceof Error && 'code' in error + && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND' + || error.code === 'ENOENT' || error.code === 'ENOTDIR') + return new Response('Presented workspace file unavailable.', { status: missing ? 404 : 500 }) + } +} diff --git a/packages/client/ui-deliverables/src/presented.ts b/packages/client/ui-deliverables/src/presented.ts new file mode 100644 index 0000000000..fd5939671d --- /dev/null +++ b/packages/client/ui-deliverables/src/presented.ts @@ -0,0 +1,52 @@ +/** Validate declared workspace paths and address their native-open actions. */ +import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' + +/** Authenticated POST route for opening a workspace file on the Host desktop. */ +export const PRESENT_OPEN_PATH = '/api/present.open' + +/** + * Validate a file declaration read from a Session log. + * @param value - decoded durable data. + * @returns whether the declaration contains a path and optional description. + */ +export function isPresentedFile(value: unknown): value is PresentedFile { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { path, description } = value as Record + return typeof path === 'string' && path.trim().length > 0 + && (description === undefined || typeof description === 'string') +} + +/** + * Build authenticated coordinates for a declared file. + * @param sessionId - owning Session. + * @param seq - deliverables/presented event sequence. + * @param index - original index in the event's files array. + * @returns same-origin file action URL. + */ +export function presentedFileUrl(sessionId: SessionId, seq: number, index: number): string { + return `${PRESENT_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` +} + +/** + * Validate a delivery event before reading its turn or file declarations. + * @param value - decoded durable event data. + * @returns whether the event identifies a turn, call, and file list. + */ +export function isPresentedData(value: unknown): value is { turn: number; callId: ToolCallId; files: unknown[] } { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { turn, callId, files } = value as Record + return typeof turn === 'number' && Number.isSafeInteger(turn) && turn >= 1 + && typeof callId === 'string' && callId.length > 0 && Array.isArray(files) +} + +/** + * Trailing path segment, the part that identifies the file at a glance. + * @param path - Slash- or backslash-separated path. + * @returns The final segment, or the whole string when separator-free. + */ +export function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts new file mode 100644 index 0000000000..b201895445 --- /dev/null +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -0,0 +1,63 @@ +/** Delivery gestures share pending state, report failures, and cancel with the plugin. */ +import { afterEach, expect, it, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { PresentedOpenController } from '../src/client/present-open.ts' + +afterEach(() => { vi.unstubAllGlobals() }) + +const id = SessionId('fork') +const url = '/api/present.open?sessionId=fork&seq=2&index=1' + +it('coalesces concurrent card and mention gestures, then allows another open', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValue(reply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const first = controller.open(id, 2, 1) + await controller.open(id, 2, 1) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(fetcher).toHaveBeenCalledWith(url, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) + expect(controller.state.getSnapshot()[url]).toBe('opening') + reply.resolve(new Response(null, { status: 204 })) + await first + expect(controller.state.getSnapshot()[url]).toBe('opened') + await controller.open(id, 2, 1) + expect(fetcher).toHaveBeenCalledTimes(2) + await controller.dispose() +}) + +it.each(['http', 'network'])('publishes retryable %s failures', async (failure) => { + const fetcher = vi.fn() + if (failure === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 })) + else fetcher.mockRejectedValueOnce(new Error('offline')) + fetcher.mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('error') + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('opened') + await controller.dispose() +}) + +it('awaits cancellation and prevents late state publication or new requests after disposal', async () => { + const aborted = Promise.withResolvers() + const release = Promise.withResolvers() + const fetcher = vi.fn((_url: string, { signal }: RequestInit) => { + signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + return release.promise + }) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const open = controller.open(id, 2, 1) + const state = controller.state.getSnapshot() + let disposed = false + const disposal = controller.dispose().then(() => { disposed = true }) + await aborted.promise + expect(disposed).toBe(false) + release.resolve(new Response(null, { status: 204 })) + await Promise.all([open, disposal]) + expect(controller.state.getSnapshot()).toBe(state) + await controller.open(id, 2, 1) + expect(fetcher).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts new file mode 100644 index 0000000000..b9319587b6 --- /dev/null +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -0,0 +1,178 @@ +/** Native delivery actions resolve the viewed Session's current workspace files. */ +import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { registerPresentOpen } from '../src/present-open.ts' +import { presentedFileUrl, PRESENT_OPEN_PATH } from '../src/presented.ts' + +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + cleanups.length = 0 + vi.restoreAllMocks() +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-present-open-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const cwd = join(root, 'workspace') + await mkdir(cwd) + const file = { path: '日记模板.docx' } + await writeFile(join(cwd, file.path), Uint8Array.of(80, 75, 0, 255)) + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + const session: { cwd?: string } = { cwd } + const readEvent = vi.fn(async (request: SessionEventReadRequest) => { + if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') + if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') + return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent } + }) + ctx.provide('sessionQuery', { readEvent } as never) + const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const })) + ctx.provide('sessionController', { openWorkspacePath: opener } as never) + const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) + const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen }) + await fiber + const handler = connection.createSharedFetchHandler('/api') + const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( + `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, + )) + return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler } +} + +describe('Presented workspace file native open route', () => { + it('opens the source itself with current bytes and leaves it intact at disposal', async () => { + const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() + const source = await realpath(join(cwd, file.path)) + expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe(`${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`) + expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404) + expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) + for (const contents of ['current source', 'edited source']) { + await writeFile(source, contents) + const response = await open() + expect(response.status).toBe(204) + expect(response.headers.get('content-disposition')).toBeNull() + expect(response.headers.get('cache-control')).toBe('no-store') + expect(opener.mock.lastCall?.[0].path).toBe(source) + expect(await readFile(opener.mock.lastCall![0].path, 'utf8')).toBe(contents) + } + expect(ctx.get('attachments')).toBeUndefined() + await fiber.dispose() + expect(await readFile(source, 'utf8')).toBe('edited source') + expect((await open()).status).toBe(404) + }) + + it('resolves inherited declarations in the viewed fork workspace', async () => { + const { root, file, readEvent, session, open, opener } = await fixture() + const fork = join(root, 'fork') + await mkdir(fork) + await writeFile(join(fork, file.path), 'child source') + readEvent.mockResolvedValueOnce({ session: { ...session, cwd: fork }, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) + expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path))) + }) + + it.each(['', '?seq=7&index=0', '?sessionId=owner&index=0', '?sessionId=owner&seq=7', + '?sessionId=owner&seq=-1&index=0', '?sessionId=owner&seq=7&index=0.1', + '?sessionId=owner&seq=9007199254740992&index=0', '?sessionId=owner&seq=7&index=9007199254740992', + ])('rejects invalid coordinates before reading: %s', async (query) => { + const { open, readEvent } = await fixture() + expect((await open(query)).status).toBe(400) + expect(readEvent).not.toHaveBeenCalled() + }) + + it('refuses unrelated Sessions, absent events, and undeclared file indices', async () => { + const { open, readEvent, session, opener } = await fixture() + expect((await open('?sessionId=other&seq=7&index=0')).status).toBe(404) + expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404) + expect((await open('?sessionId=owner&seq=7&index=1')).status).toBe(404) + readEvent.mockResolvedValueOnce({ session, target: { type: 'turn/start' } as SessionEvent }) + expect((await open()).status).toBe(404) + expect(opener).not.toHaveBeenCalled() + }) + + it.each([null, [], 'invalid', {}, { turn: 1, callId: 'call', files: null }, + { turn: 1, callId: 'call', files: [null] }, { turn: 1, callId: 'call', files: [{ path: '' }] }, + { turn: 1, callId: 'call', files: [{ path: 'a', description: 1 }] }, + ])('refuses malformed recorded delivery data: %j', async (data) => { + const { open, readEvent, session, opener } = await fixture() + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data } as unknown as SessionEvent }) + expect((await open()).status).toBe(404) + expect(opener).not.toHaveBeenCalled() + }) + + it('reports removed files, directories, and absent workspaces without launching', async () => { + const { cwd, file, session, open, opener } = await fixture() + await unlink(join(cwd, file.path)) + expect((await open()).status).toBe(404) + file.path = '.' + expect((await open()).status).toBe(404) + delete session.cwd + expect((await open()).status).toBe(404) + expect(opener).not.toHaveBeenCalled() + }) + + it('refuses traversal and a source replaced by a symlink outside the workspace', async () => { + const { root, cwd, file, open, opener } = await fixture() + const outside = join(root, 'outside.txt') + await writeFile(outside, 'outside') + const source = join(cwd, file.path) + await unlink(source) + await symlink(outside, source) + expect((await open()).status).toBe(403) + file.path = '../outside.txt' + expect((await open()).status).toBe(403) + file.path = outside + expect((await open()).status).toBe(403) + expect(opener).not.toHaveBeenCalled() + }) + + it('reports query and launcher failures without leaking Host paths and allows retry', async () => { + const { open, readEvent, opener } = await fixture() + readEvent.mockRejectedValueOnce(new SessionQueryError('corrupt', 'SESSION_QUERY_CORRUPT_SESSION')) + expect((await open()).status).toBe(500) + opener.mockRejectedValueOnce(new Error('/private/host/path')) + const response = await open() + expect(response.status).toBe(500) + expect(await response.text()).not.toContain('/private/host/path') + expect((await open()).status).toBe(204) + }) + + it('honors cancellation before lookup', async () => { + const { open, readEvent } = await fixture() + const controller = new AbortController() + controller.abort(new Error('cancelled')) + await expect(open(undefined, controller.signal)).rejects.toThrow('cancelled') + expect(readEvent).not.toHaveBeenCalled() + }) + + it('disposal aborts and awaits a pending native launch', async () => { + const entered = Promise.withResolvers() + const aborted = Promise.withResolvers() + const release = Promise.withResolvers() + const { open, fiber, opener } = await fixture() + opener.mockImplementation(async (_request, signal) => { + signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + entered.resolve(undefined) + await release.promise + signal.throwIfAborted() + return { opened: true } + }) + const request = open().catch((error: unknown) => error) + await entered.promise + let disposed = false + const disposal = fiber.dispose().then(() => { disposed = true }) + await aborted.promise + expect(disposed).toBe(false) + release.resolve(undefined) + await Promise.all([request, disposal]) + }) +}) diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx new file mode 100644 index 0000000000..05d6b99540 --- /dev/null +++ b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +/** Present UI derives statuses and details from durable tool records. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { PresentRow } from '../src/client/PresentRow.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) +type Props = Parameters[0] +const running: RunningToolCall = { callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] } +const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'p', call: { name: 'present', argsRaw: running.argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Presented report.txt (4 bytes)' }], isError: false, subCalls: [] } +function props(block: Props['block'], inspect?: () => void): Props { + return { block, callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props +} + +it('discloses the saved result and offers call inspection', () => { + const inspect = vi.fn() + const view = render() + expect(view.getByText('Delivered')).toBeTruthy() + expect(view.getByText('report.txt')).toBeTruthy() + expect(view.queryByText('Presented report.txt (4 bytes)')).toBeNull() + const row = view.getByRole('button') + fireEvent.keyDown(row, { key: 'Enter' }) + expect(view.getByText('Presented report.txt (4 bytes)')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'Inspect call' })) + expect(inspect).toHaveBeenCalledOnce() + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('false') +}) + +it.each([ + [running, 'running', 'Delivering'], + [{ ...settled, isError: true }, 'error', 'Delivery failed'], + [{ ...settled, error: { name: 'Interrupted', code: 'interrupted' } }, 'stopped', 'Interrupted'], +] as const)('renders call lifecycle without claiming failed delivery', (block, state, label) => { + const view = render() + expect(view.container.querySelector('[data-tool="present"]')?.getAttribute('data-state')).toBe(state) + expect(view.getByText(label)).toBeTruthy() + expect(view.queryByText('Delivered')).toBeNull() +}) + +it.each(['', '{', 'null', '[]', '{"files":null}', '{"files":[null,{},1,{"path":false},{"path":"good.txt"}]}'])('tolerates partial arguments %s', (argsRaw) => { + const view = render() + expect(view.getByText('Delivering')).toBeTruthy() + expect(view.queryByRole('button')).toBeNull() +}) + +it('shows orphaned error details and non-text results', () => { + const view = render() + fireEvent.click(view.getByRole('button')) + expect(view.getByText('Missing: missing')).toBeTruthy() + view.rerender() + expect(view.container.textContent).toContain('Recorded detail') + view.rerender() + expect(view.queryByRole('button')).toBeNull() +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9c006c93e7..9b2d38caaa 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -21,15 +21,26 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' +import { PresentedOpenController } from '../src/client/present-open.ts' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' import { - basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, + basename, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, selectProducedFiles, type DeliverablesTurnData, } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { en, zh } from '../src/client/locales.ts' +import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +function openProps(controller = new PresentedOpenController()) { + return { + openPresented: vi.fn((...args: Parameters) => controller.open(...args)), + usePresentedOpen: (select: (state: ReturnType) => T): T => + select(controller.state.getSnapshot()), + } +} + afterEach(() => { cleanup() vi.restoreAllMocks() @@ -368,6 +379,10 @@ describe('produced-file Turn data', () => { )) .toThrow('deliverables start requires turn/start') expect(deliverablesDefinition.update(context, unrelated)).toBe(state) + for (const files of [[], [null, { path: '' }]]) { + const declaration = matched(at(3, 'deliverables/presented', { turn: 1, callId: 'present', files }), 'update') + expect(deliverablesDefinition.update(context, declaration)).toBe(state) + } }) it('replays a tail page once prepend supplies its missing Turn start', () => { @@ -494,7 +509,7 @@ describe('plugin registration', () => { // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', - children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, 'tool.call.toolview': { kind: 'keyed', scope: 'session' } }, } as never, () => null) // ui-theme's Appearance row binds a durable scope through these two. const session = { @@ -513,9 +528,8 @@ describe('plugin registration', () => { await fiber.await() const [entry] = ctx.slots.entries('conversation.chat.turnTail') expect(entry).toBeDefined() - // The row needs no injected Host capability: it hands a path to its owner - // and nothing in it reaches the local machine. - expect(entry?.inject).toBeUndefined() + expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(1) + expect(entry?.inject).toBeDefined() // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. @@ -526,15 +540,106 @@ describe('plugin registration', () => { (path) => { opened.push(path) }, ) const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions') - const mentions = service?.forClosing(owner) + const mentions = service?.forClosing(owner, SessionId('viewed-session')) mentions?.resolve('report.html')?.open() expect(opened).toEqual(['site/report.html']) + const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', seq: 2, index: 0 }] }, 3) + service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() + expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) + const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + await face.openPresented(SessionId('child-session'), 2, 0) + expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. - expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined() + expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() await fiber.dispose() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0) // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) }) + + +describe('presented files', () => { + const file = (path = 'report.docx') => ({ path }) + + it('replays deliveries without mutation calls, preserves indices, and isolates turns', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: [null, { ...file(), description: 'Final report' }] }), + at(3, 'deliverables/presented', { turn: 1, callId: 'again', files: [{ ...file(), description: 'Updated report' }] }), + at(4, 'turn/end', { turn: 1 }), + at(5, 'turn/start', { turn: 2 }), + ]) + const first = presentedForClosing(tailOwner(deliverablesOf(value), 3)) + expect(first).toMatchObject([{ path: 'report.docx', seq: 2, index: 1, description: 'Final report' }]) + expect(presentedForClosing(tailOwner(deliverablesOf(value), 4))) + .toMatchObject([{ path: 'report.docx', seq: 3, description: 'Updated report' }]) + expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull() + }) + + it('uses the viewed fork Session in every open action and retains all delivered files', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: Array.from({ length: 8 }, (_, i) => file(`report-${i}.docx`)) }), + ]) + const owner = tailOwner(deliverablesOf(value), 3) + const matched = selectDeliverables(owner)! + const props = openProps() + props.openPresented.mockResolvedValue(undefined) + const view = render() + expect(view.getAllByRole('button')).toHaveLength(8) + expect(view.queryByRole('link')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0) + expect(view.queryByText('Produced')).toBeNull() + }) +}) + + +it.each([null, [], 'invalid'])('declines non-object delivery data: %j', (data) => { + expect(deliverablesDefinition.match(at(1, 'deliverables/presented', data).event)).toBeNull() +}) + +it.each([{}, { turn: '1', callId: 'bad', files: [] }, + { turn: 1.5, callId: 'bad', files: [] }, { turn: 0, callId: 'bad', files: [] }, + { turn: 1, files: [] }, { turn: 1, callId: '', files: [] }, { turn: 1, callId: 'bad', files: null }, +])('ignores malformed delivery data and keeps the existing produced row: %j', (data) => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(2, 'write-a', 'write', { file_path: 'a.txt', content: 'a' }), + result(3, 'write-a'), + at(4, 'deliverables/presented', data), + ]) + const owner = tailOwner(deliverablesOf(value), 5) + const matched = selectDeliverables(owner)! + const view = render() + expect(view.getByText('Produced')).toBeTruthy() + expect(view.queryByText('Deliverables')).toBeNull() +}) + +it('shows file metadata and descriptions without hiding extensionless deliveries', () => { + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByText('Quarterly summary')).toBeTruthy() + expect(view.getByText('TXT')).toBeTruthy() + expect(view.getByText('File')).toBeTruthy() + expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app') +}) + + +it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => { + const controller = new PresentedOpenController() + controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase }) + const props = openProps(controller) + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) + expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening') +}) diff --git a/packages/client/ui-deliverables/tests/prompt.client.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts similarity index 88% rename from packages/client/ui-deliverables/tests/prompt.client.spec.ts rename to packages/client/ui-deliverables/tests/prompt.host.spec.ts index 9c2ee24471..8f342099fd 100644 --- a/packages/client/ui-deliverables/tests/prompt.client.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -16,6 +16,9 @@ describe('ui-deliverables node plugin', () => { it('registers final-response file-reference guidance only while mounted', async () => { ctx = new Context() await ctx.plugin(SystemPrompt, { personaPrefix: '' }) + ctx.provide('connection', { fetch: { register: () => () => {} } } as never) + ctx.provide('sessionQuery', {} as never) + ctx.provide('sessionController', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json new file mode 100644 index 0000000000..3b8f95c87c --- /dev/null +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -0,0 +1,57 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src/client", + "src/presented.ts", + "src/css-modules.d.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../connection/tsconfig.client.json" + }, + { + "path": "../locale" + }, + { + "path": "../store" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-chat" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../ui-tool" + }, + { + "path": "../../fs/tool-present" + } + ] +} diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json new file mode 100644 index 0000000000..0123290583 --- /dev/null +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/presented.ts", + "src/present-open.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../connection/tsconfig.host.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../session-query/session-query" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../fs/tool-present" + }, + { + "path": "../../api/session-controller/tsconfig.host.json" + } + ] +} diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index a99881d93b..2eca820546 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/packages/client/ui-deliverables/tsconfig.json @@ -1,48 +1,11 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ { - "path": "../../api/remotes/tsconfig.client.json" + "path": "./tsconfig.host.json" }, { - "path": "../../../vendor/cordis" - }, - { - "path": "../connection/tsconfig.client.json" - }, - { - "path": "../locale" - }, - { - "path": "../store" - }, - { - "path": "../ui-conversation" - }, - { - "path": "../ui-chat" - }, - { - "path": "../ui-renderer" - }, - { - "path": "../ui-primitives" - }, - { - "path": "../ui-slots" - }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../core/session" + "path": "./tsconfig.client.json" } ] } diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index a1b3e3296b..ec0a9d8ed0 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -59,7 +56,8 @@ "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index a6f9e8ecbc..9b370e32bb 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index bd0993ceba..f28d39fc71 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/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-dockkit/README.md -README.md: e012295852ad3c957695fcfbd0568a45b3f54856 -README.zh.md: 2423887d60460651a224ffe1cb0fc9b61363c0e2 +README.md: f4f76072bd16cd75bff74715f8f9437348ee036a +README.zh.md: 39e20b42bfabb49630b1c9e108b43010d6e7b199 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index e012295852..f4f76072bd 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -37,7 +37,7 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a - `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it. - `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does). -**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. +**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. The optional `canCloseTab(tabId)` hides the chip, menu, and floating close controls; the embedder enforces closing in its intent handler. A chip is a capsule carrying its close control when allowed; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. ## Embedding it @@ -74,6 +74,8 @@ These are not stylistic; each one fixes a defect found in a real browser. ## Build shape +Its static ESM retains third-party imports for the Web shell's Vite build; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). + The package is statically linked: tsdown's `staticLinked` preset emits one browser ESM bundle at `lib/index.js` (every bare specifier stays an import, sourcemaps chain to the sources) and ships the stylesheet under `lib/` at its `src`-relative path, and the Web shell resolves the package name and bundles that artifact itself, so vite stays the only owner of class hashing. One consequence is load-bearing — the kit keeps **one** stylesheet, `dockkit.module.css`, because a consumer de-duplicates injected sheets by file name and a collision would drop one silently. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 2423887d60..39e20b42bf 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" - `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。 -**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 +**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。可选的 `canCloseTab(tabId)` 隐藏 chip、菜单和浮窗的关闭控件;嵌入方在意图处理器中执行关闭限制。chip 是一个胶囊,在允许关闭时携带关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 ## 如何嵌入 @@ -74,6 +74,8 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt ## 构建形态 +静态 ESM 为 Web 壳的 Vite 构建保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 + 本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果是承重的——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。 diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index 984daeca2a..968d1ba505 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-dockkit", "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -22,18 +22,15 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 4ff0d3789d..6c074c86ca 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -45,6 +45,8 @@ export interface DockSurfaceProps { * end controls where they are and the chips as the only shrinking part. */ readonly canAddTab?: (paneId: PaneId) => boolean + /** Whether a tab offers close controls; defaults to true. Called per tab on every render. */ + readonly canCloseTab?: (tabId: TabId) => boolean readonly intents: DockIntents readonly labels: DockLabels readonly renderTab: TabRenderer @@ -154,7 +156,7 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean { /** The split tree and the gestures over it. */ export function DockSurface({ - state, canSplit, canAddTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, + state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false, }: DockSurfaceProps): ReactNode { const surface = useRef(null) @@ -267,6 +269,7 @@ export function DockSurface({ splitBlock, hideSplitAtCapacity, canAddTab: canAddTab ?? ALWAYS, + canCloseTab: canCloseTab ?? ALWAYS, dropTarget: preview.dropTarget, horizontalDrops: dropZones === 'horizontal', draggingTabId: preview.draggingTabId, diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index 3c7fa9c1d9..4247928b21 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -15,7 +15,7 @@ import { useState } from 'react' import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' -import type { FloatRect, LayoutState, PaneId } from '../contract/types.ts' +import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' import { movedRect, resizedRect } from '../engine/geometry.ts' import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts' @@ -28,6 +28,8 @@ export interface FloatLayerProps { readonly intents: DockIntents readonly labels: DockLabels readonly renderTab: TabRenderer + /** Whether a floating tab offers its close control; defaults to true. Called per tab on every render. */ + readonly canCloseTab?: (tabId: TabId) => boolean /** The panel header's title content; omit to show the record's `title` text (see `DockSurfaceProps`). */ readonly renderTabTitle?: TabRenderer } @@ -60,7 +62,7 @@ function raised(state: LayoutState, paneId: PaneId): boolean { } /** Every floating panel, in z order. */ -export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }: FloatLayerProps): ReactNode { +export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode { const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined) const begin = useGesture(() => { setPreview(undefined) }) @@ -127,16 +129,18 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle } > ⇤ - + {(canCloseTab?.(tab.id) ?? true) && ( + + )}
{renderTab(tab)}
void /** Dismiss without acting. */ readonly onDismiss: () => void - /** Embedder items, rendered after the kit's own; absent means none. */ + /** Embedder ARIA menu items, rendered after the kit's own; absent means none. */ readonly extras: ReactNode } @@ -47,19 +49,18 @@ function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties { } /** The actions menu body, anchored to the control that opened it. */ -export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode { +export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extras }: TabMenuProps): ReactNode { const self = useRef(null) const [position, setPosition] = useState(undefined) + const hasItems = canCloseTab || Children.toArray(extras).some(item => item !== '') useLayoutEffect(() => { - /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */ if (self.current === null) return setPosition(placeMenu(anchor, self.current)) - }, [anchor]) + }, [anchor, canCloseTab, hasItems]) useEffect(() => { const menu = self.current - /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */ if (menu === null) return undefined // A press anywhere but inside the menu dismisses it; one with no element // target (dispatched to the window itself) counts as outside. @@ -71,8 +72,9 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP // so the menu must be gone before that handler runs. window.addEventListener('pointerdown', onPointerDown, true) return () => { window.removeEventListener('pointerdown', onPointerDown, true) } - }, [onDismiss]) + }, [onDismiss, hasItems]) + if (!hasItems) return null return createPortal(
{ event.stopPropagation() }} onClick={(event) => { event.stopPropagation() }} > - + {canCloseTab && ( + + )} {/* Embedder items last: the kit's own item is the same in every menu, so a reader looks for it in the same place every time. */} {extras} diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index a4a9cdc6f1..1258002996 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -3,7 +3,7 @@ * active tab's body with the dock preview overlay. Presentational; every gesture * leaves through `PaneCallbacks`, and the body itself comes from `renderTab`. * - * A chip is a capsule carrying one control, its close, at its right end; the + * A chip is a capsule carrying an optional close control at its right end; the * context menu (secondary press) carries the same close plus whatever the * embedder appends. The chips sit in their own box, the strip's one shrinking * part: in a narrow pane they ellipsize and then clip there, so the add @@ -136,6 +136,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { {pane.tabs.map((tabId, index) => { const tab = getTab(state, tabId) const selected = tabId === pane.activeTabId + const canClose = callbacks.canCloseTab(tabId) return ( {stripIndex === index &&
} @@ -188,25 +189,28 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { }} > {callbacks.renderTabTitle?.(tab) ?? tab.title} - + {canClose && ( + + )} {menu?.tabId === tabId && ( { setMenu(undefined); callbacks.onCloseTab(tabId) }} onDismiss={() => { setMenu(undefined) }} extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })} diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index 2e995a2700..58907277e8 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -269,6 +269,10 @@ border-radius: 6px; } +.menu:not(:has([role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'])) { + display: none; +} + .menuItem { padding: 5px 8px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-dockkit/src/components/render.ts b/packages/client/ui-dockkit/src/components/render.ts index 1bc3c703f6..71d0b1ec9e 100644 --- a/packages/client/ui-dockkit/src/components/render.ts +++ b/packages/client/ui-dockkit/src/components/render.ts @@ -27,6 +27,8 @@ export interface PaneCallbacks { readonly hideSplitAtCapacity?: boolean /** Whether a pane's strip draws the add control. */ readonly canAddTab: (paneId: PaneId) => boolean + /** Whether a tab's chip and menu offer close. */ + readonly canCloseTab: (tabId: TabId) => boolean /** Live drop preview, or `undefined` while nothing is being dragged. */ readonly dropTarget: DropTarget | undefined /** Show both horizontal landing regions while a body split is being targeted. */ diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts index eb1abf8d5f..3ca282dd3e 100644 --- a/packages/client/ui-dockkit/src/contract/adapter.ts +++ b/packages/client/ui-dockkit/src/contract/adapter.ts @@ -48,7 +48,7 @@ export type TabRenderer = (tab: TabRecord) => ReactNode * because the menu closes on its own items only. * @param tab - the tab whose menu is open. * @param dismiss - close the menu without acting. - * @returns the extra items, or nothing. + * @returns extra actions with ARIA menuitem, menuitemcheckbox, or menuitemradio roles, or nothing. */ export type TabMenuExtras = (tab: TabRecord, dismiss: () => void) => ReactNode diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 0ff855f694..23b49d2237 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -8,8 +8,10 @@ * jsdom lays nothing out, so the gesture specs hand the surface a layout: panes * of one width side by side, each 600px tall with a 36px strip and 100px chips. */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { DockIntents } from '../src/contract/adapter.ts' import type { PaneId, TabId } from '../src/contract/types.ts' import { DockController } from '../src/engine/controller.ts' @@ -18,7 +20,7 @@ import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/engine/constraints.ts import { floatRectAt } from '../src/engine/geometry.ts' import { DockSurface, type DockSurfaceProps } from '../src/components/DockSurface.tsx' import type { TabMenuExtras } from '../src/contract/adapter.ts' -import { FloatLayer } from '../src/components/FloatLayer.tsx' +import { FloatLayer, type FloatLayerProps } from '../src/components/FloatLayer.tsx' import { dockPaneIds, getPane } from '../src/engine/tree.ts' import { TEST_LABELS, asPane, asTab, fileTab, seededController } from './fixtures.client.ts' @@ -53,7 +55,7 @@ function renderSurface( intents: DockIntents, canSplit = true, renderTabMenuItems?: TabMenuExtras, - options: Pick = {}, + options: Pick = {}, ) { const snapshot = controller.getSnapshot() return render( @@ -456,6 +458,83 @@ describe('DockSurface', () => { expect(screen.queryByRole('menu')).toBeNull() }) + it('offers close only for tabs the embedder allows', () => { + const controller = seededController() + const allowed = controller.openContent({ contentId: 'resource:a', title: 'A', kind: 'test' }) + const intents = spyIntents() + renderSurface(controller, intents, true, undefined, { canCloseTab: tabId => tabId === allowed }) + expect(screen.getByRole('button', { name: TEST_LABELS.closeTab }).getAttribute('data-dockkit-tab-close')).toBe(allowed) + fireEvent.contextMenu(screen.getByRole('tab', { name: 'Start' })) + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.contextMenu(screen.getByRole('tab', { name: /A/u })) + fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })) + expect(intents.closeTab).toHaveBeenCalledExactlyOnceWith(allowed) + }) + + it.each([ + ['absent', undefined], + ['null', null], + ['conditional', false], + ['empty array', []], + ['empty text', ''], + ])('hides close and creates no popup when extras are %s', (_, extras) => { + const controller = seededController() + const intents = spyIntents() + renderSurface(controller, intents, true, extras === undefined ? undefined : () => extras, { canCloseTab: () => false }) + expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull() + fireEvent.contextMenu(screen.getByRole('tab')) + expect(screen.queryByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeNull() + expect(screen.queryByRole('menu')).toBeNull() + expect(intents.closeTab).not.toHaveBeenCalled() + }) + + it('updates chip and open-menu close controls when eligibility props change', () => { + const controller = seededController() + const intents = spyIntents() + const props: DockSurfaceProps = { + state: controller.getSnapshot().state, + canSplit: true, + intents, + labels: TEST_LABELS, + renderTab: () => null, + canCloseTab: () => false, + } + const { rerender } = render() + expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull() + rerender( true} />) + expect(screen.getByRole('button', { name: TEST_LABELS.closeTab })).toBeDefined() + fireEvent.contextMenu(screen.getByRole('tab')) + expect(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeDefined() + rerender() + expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull() + expect(screen.queryByRole('menu')).toBeNull() + rerender( true} />) + fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })) + expect(intents.closeTab).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeTab })) + expect(intents.closeTab).toHaveBeenCalledTimes(2) + }) + + it.each([false, true])('hides the popup when an extras component renders no content inside a wrapper: %s', (wrapped) => { + const EmptyExtras = () => null + renderSurface(seededController(), spyIntents(), true, + () => wrapped ?
: , + { canCloseTab: () => false }) + fireEvent.contextMenu(screen.getByRole('tab')) + const menu = document.querySelector('[data-dockkit-tab-menu]') + if (menu === null) throw new Error('expected the extras component menu') + // Vitest stubs CSS Modules; bind the real stylesheet's menu selector to its generated class. + const style = document.createElement('style') + style.textContent = readFileSync(resolve(import.meta.dirname, '../src/components/dockkit.module.css'), 'utf8') + .replaceAll(/\.menu(?=[:\s{])/g, `.${menu.className}`) + document.head.append(style) + onTestFinished(() => { style.remove() }) + expect(menu.querySelectorAll('[role^="menuitem"]')).toHaveLength(0) + expect(getComputedStyle(menu).display).toBe('none') + expect(screen.queryByRole('menu')).toBeNull() + }) + it('toggles the menu closed on a second secondary press, and dismisses it on a press anywhere else', () => { const controller = seededController() renderSurface(controller, spyIntents()) @@ -501,7 +580,7 @@ describe('DockSurface', () => { expect(document.querySelector('[data-dockkit-tab-more]')).toBeNull() }) - it('appends embedder menu items after its own, and hands them the tab and a dismiss', () => { + it.each([true, false])('keeps embedder menu items and their tab and dismiss when close is allowed: %s', (canClose) => { const controller = seededController() const acted = vi.fn<(contentId: string) => void>() const extras: TabMenuExtras = (tab, dismiss) => ( @@ -514,17 +593,20 @@ describe('DockSurface', () => { embedder item ) - renderSurface(controller, spyIntents(), true, extras) + const intents = spyIntents() + renderSurface(controller, intents, true, extras, { canCloseTab: () => canClose }) fireEvent.contextMenu(screen.getByRole('tab')) // Order is contract: the kit's own item stays in the same place in every // menu, so an embedder item cannot displace it. - expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ - TEST_LABELS.closeTab, 'embedder item', - ]) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual( + canClose ? [TEST_LABELS.closeTab, 'embedder item'] : ['embedder item'], + ) + expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab }) !== null).toBe(canClose) fireEvent.click(screen.getByTestId('extra')) expect(acted).toHaveBeenCalledWith('seed:start') + expect(intents.closeTab).not.toHaveBeenCalled() expect(screen.queryByRole('menu')).toBeNull() }) @@ -895,7 +977,12 @@ describe('FloatLayer', () => { } /** One floating panel over a spied intent set. */ - function floating(): { intents: ReturnType; paneId: PaneId; tabId: TabId; panel: HTMLElement } { + function floating(canCloseTab?: FloatLayerProps['canCloseTab']): { + intents: ReturnType + paneId: PaneId + tabId: TabId + panel: HTMLElement + } { const controller = seededController() const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const paneId = controller.floatTab(tabId, { x: 100, y: 80, width: 300, height: 200 }) @@ -906,6 +993,7 @@ describe('FloatLayer', () => { intents={intents} labels={TEST_LABELS} renderTab={tab =>

{tab.title}

} + {...canCloseTab === undefined ? {} : { canCloseTab }} />, ) const panel = document.querySelector(`[data-dockkit-float="${paneId}"]`) @@ -927,6 +1015,17 @@ describe('FloatLayer', () => { expect(intents.closeTab).toHaveBeenCalledWith(tabId) }) + it('hides a floating tab close control when the embedder disallows it', () => { + const canCloseTab = vi.fn(() => false) + const { intents, tabId } = floating(canCloseTab) + expect(canCloseTab).toHaveBeenCalledWith(tabId) + expect(screen.queryByRole('button', { name: TEST_LABELS.closeFloat })).toBeNull() + expect(screen.getByTestId('float-body').textContent).toBe('a.txt') + fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat })) + expect(intents.unfloatPane).toHaveBeenCalledTimes(1) + expect(intents.closeTab).not.toHaveBeenCalled() + }) + it('raises a panel on a press on its body, but not from a press on its controls, grip, or corner', () => { const { intents, lower, panel, part } = twoFloats() fireEvent.pointerDown(panel(lower)) diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 1a44a08707..5d516249f9 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index b01992a297..65e30abb16 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -61,7 +58,8 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 5bec169696..cd0ea7ebe9 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index eb1cbebbee..8d680bc72f 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/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-layout/README.md -README.md: 0e353e21f39ba8a84e661f1c540db2c2424bcbfa -README.zh.md: 16471fdabb46bbfb35158ef85aa2544db4ceae93 +README.md: 4fe19fb9ec0a549dc76ca311d0564cbe86c9f5f9 +README.zh.md: 7a61e7808ffb3d81214388a2792a901ced8d9564 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 0e353e21f3..4fe19fb9ec 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -25,7 +25,9 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a ## Use this package -The root slot composes the sidebar, conversation, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. +The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. + +Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition. ### Theme presentation @@ -39,7 +41,9 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
Implementation internals — click to expand -One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. +`selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts. + +One registration declares four child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
@@ -51,7 +55,7 @@ One registration declares four child slots and binds `ctx.layout` methods `toggl Read these pages when the layout surface is not enough. They move from the frame to the columns it renders and the theme it presents. - [ui-sidebar](../ui-sidebar/README.md) — occupies the `sidebar` column and its seats. -- [ui-conversation](../ui-conversation/README.md) — occupies the `conversation` column. +- [ui-conversation](../ui-conversation/README.md) — occupies the `main` key `conversation`. - [ui-sidebar-right](../ui-sidebar-right/README.md) — occupies the `rightbar` column with one docking surface per session. - [ui-theme](../ui-theme/README.md) — the theme seam whose resolved snapshots the presenter consumes. - [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 16471fdabb..7a61e7808f 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -25,7 +25,9 @@ kind: "package-reference" ## 使用本包 -本插件在 root slot 组合侧栏、会话与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。 +本插件在 root slot 组合侧栏、中央内容与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。 + +全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。 ### 主题呈现 @@ -39,7 +41,9 @@ kind: "package-reference"
实现细节——点击展开 -一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 +`selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费者在提交导航或搬移草稿前检查 signal。 + +一次注册声明四个子slot并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放列宽。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
@@ -51,7 +55,7 @@ kind: "package-reference" 当布局面不够用时阅读以下页面。它们从框架进入它所渲染的栏与它所呈现的主题。 - [ui-sidebar](../ui-sidebar/README.zh.md)——占据 `sidebar` 栏及其座位。 -- [ui-conversation](../ui-conversation/README.zh.md)——占据 `conversation` 栏。 +- [ui-conversation](../ui-conversation/README.zh.md)——占据 `main` 中的 `conversation` key。 - [ui-sidebar-right](../ui-sidebar-right/README.zh.md)——以每会话一个停靠面占据 `rightbar` 栏。 - [ui-theme](../ui-theme/README.zh.md)——呈现器消费其解析快照的主题 seam。 - [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册槽位。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index d2fe553dc8..a2c025f39d 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -45,6 +45,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 905341f503..05bb4b9494 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -3,10 +3,9 @@ * shell renders only 'root'). Owns the grid tracks (sidebar | center | * rightbar), the drag handles (pointer capture + rAF throttle), the column * solve (columns.ts), and the child-slot render decisions: the sidebar slot - * renders HERE with live parameters from that solve, and the session-aware - * occupants render in fixed column positions; the strict right-column entry - * gates itself on current-session availability while the session-maybe - * conversation retains identity. + * receives live parameters from that solve. The root-scoped main slot selects + * the Conversation or a global panel. Each column occupant owns its Session + * binding and reports the geometry it needs. * * The right column is a track, not a box: its occupant draws its panel anchored * to the frame's right edge at the resolved normal width, and the @@ -15,7 +14,7 @@ * track but hides the outer resize handle. Everything arrives through the framework * shares — zero cordis or framework imports, zero self-made hooks. */ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, @@ -28,7 +27,7 @@ import css from './AppFrame.module.css' /** Full composed props: runtime share + child-slot render share + store share. */ export type AppFrameProps = & PropsRuntime<'root'> - & PropsRenderSlots<'sidebar' | 'conversation' | 'rightbar' | 'shell.overlay'> + & PropsRenderSlots<'sidebar' | 'main' | 'rightbar' | 'shell.overlay'> & PropsStore> & PropsLocale<'common'> @@ -37,6 +36,12 @@ function CenterColumn(props: { children?: ReactNode }) { return
{props.children}
} +/** Subscribe to the main key without subscribing the column frame to each panel id. */ +function MainPanel({ usePanelInfo, renderSlot }: Pick, 'usePanelInfo'> & PropsRenderSlots<'main'>) { + const panelId = usePanelInfo(info => info.activePanelId) + return renderSlot('main', {}, { entryKey: panelId ?? 'conversation' }) +} + /** * Right column grid item. Zero-width unless the occupant asked for a track; the * occupant's panel is positioned against the column's right edge, which never @@ -116,18 +121,14 @@ function DragHandle(props: { side: 'sidebar' | 'rightbar'; left: number; onStart export function AppFrame({ useStore, useSessions, + usePanelInfo, actions, renderSlot, - SessionProvider, t, }: AppFrameProps) { - const panels = useStore(s => s) - const documentTitle = useSessions((s) => { - const current = s.current - return current === undefined ? undefined : s.byId[current]?.title - }) + const layoutInfo = useStore(state => state.layoutInfo) const frameRef = useRef(null) - const viewport = panels.viewportWidth + const viewport = layoutInfo.viewportWidth // Track the frame's own box (not the window): rAF-throttled ResizeObserver. useLayoutEffect(() => { @@ -157,15 +158,15 @@ export function AppFrame({ }, [actions]) const narrow = viewport < SIDEBAR_AUTO_COLLAPSE - const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0 + const sidebarCollapsed = narrow ? !layoutInfo.narrowExpanded : layoutInfo.sidebar === 0 const sidebarPreference = sidebarCollapsed ? 0 - : panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar - const rightbarPreference = panels.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO + : layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : layoutInfo.sidebar + const rightbarPreference = layoutInfo.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO // Opening on a narrow frame collapses the left sidebar. Eligibility must // include that space before the occupant's first shown report arrives. - const normal = computeColumns(viewport, !panels.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference) - const cols = computeColumns(viewport, sidebarPreference, panels.rightbarTrack ? rightbarPreference : 0) + const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference) + const cols = computeColumns(viewport, sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0) const colsRef = useRef(cols) colsRef.current = cols const rightbarWidth = useRef(normal.rightbar) @@ -189,6 +190,14 @@ export function AppFrame({ actions.setRightbar(rightbarBase.current - dx) }, [actions]) const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild') + const sidebar = useMemo(() => renderSlot('sidebar', { + collapsed: sidebarCollapsed, + width: cols.sidebar, + }), [renderSlot, sidebarCollapsed, cols.sidebar]) + const main = useMemo(() => ( + + ), [usePanelInfo, renderSlot]) + const overlays = useMemo(() => renderSlot('shell.overlay', {}), [renderSlot]) return (
- {/* Render-site slot call with live concession output: a closed - sidebar keeps the mounted slot at the compact-rail width, and the - component sees its rendered state as owner params decided here - (collapsed follows the resolved rail, so a derived auto-collapse - renders the rail UI too). */} - {renderSlot('sidebar', { - collapsed: sidebarCollapsed, - width: cols.sidebar, - })} + {sidebar}
<> - {/* Both column occupants stay at fixed tree positions from first - paint — no loading gate: a bare status line reads worse than - the shell's own pending rendering. The conversation is - session-maybe; SessionProvider withholds the strict right-column - entry while no session is current. */} - {renderSlot('conversation', {})} + {main} - {/* Strict session entry: with no session there is no surface, and the - column is an empty zero-width track. The occupant receives the - panel width it should draw at; the track is the frame's business. */} - - {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })} - + {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
- {renderSlot('shell.overlay', {})} + {overlays}
{/* The collapsed rail is fixed-width: no resize handle while closed. */} {!sidebarCollapsed && } - {panels.rightbarShown && !panels.rightbarFullscreen && normal.rightbar > 0 && ( + {layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && ( )}
diff --git a/packages/client/ui-layout/src/client/DocumentTitle.tsx b/packages/client/ui-layout/src/client/DocumentTitle.tsx index 4060a3b187..69cd5f4cfe 100644 --- a/packages/client/ui-layout/src/client/DocumentTitle.tsx +++ b/packages/client/ui-layout/src/client/DocumentTitle.tsx @@ -1,9 +1,9 @@ +/** Browser title selection follows the active main panel without subscribing the frame. */ import { useEffect } from 'react' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' /** Props for the browser title projection. */ -export interface DocumentTitleProps { - /** Durable title of the selected session, or undefined for the product title. */ - title?: string +export type DocumentTitleProps = Pick, 'useSessions' | 'usePanelInfo'> & { /** Build-configured or localized product title. */ productTitle: string } @@ -14,7 +14,12 @@ export interface DocumentTitleProps { * @param props - Selected session title projection. * @returns No rendered content. */ -export function DocumentTitle({ title, productTitle }: DocumentTitleProps): null { +export function DocumentTitle({ useSessions, usePanelInfo, productTitle }: DocumentTitleProps): null { + const showSessionTitle = usePanelInfo(info => info.activePanelId === null) + const title = useSessions((state) => { + const current = state.current + return !showSessionTitle || current === undefined ? undefined : state.byId[current]?.title + }) useEffect(() => { document.title = title === undefined ? productTitle : `${title} — ${productTitle}` return () => { document.title = productTitle } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 649215b96d..ffbbd68bb9 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -3,8 +3,8 @@ * the runtime's built-in 'root' slot and, in the same breath, declares the * four child slots (declaration = exclusive render authority), seats the * layout store (panel geometry), and wires the panel-action service face. - * ctx.layout is the cross-plugin panel-action contract; navigation state lives - * with the runtime sessions service. A second effect seats the theme + * ctx.layout selects the main panel and controls column geometry; Session + * selection belongs to the Session Controller. A second effect seats the theme * presenter, which projects ctx.theme snapshots onto document.body. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' @@ -12,7 +12,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' -import type { PanelActions } from './service.ts' +import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { PanelInfo } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutController } from './service.ts' @@ -24,7 +25,10 @@ import { ThemePresenter } from './theme-presenter.ts' // OwnerShare contracts below are the render-side halves registrants compose // against; the frame components and the store factory are package-internal. export { LayoutController } from './service.ts' -export type { ILayout } from './service.ts' +export type { ILayout, MainPanelId, PanelInfo } from './service.ts' + +/** Selector hook over root-scoped panel selection. */ +export type UsePanelInfo = SnapshotSelectorHook declare module '@deepseek-ai/cordis' { interface Context { @@ -34,6 +38,11 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Subscribe to the selected main panel independently of parent renders. */ + usePanelInfo: UsePanelInfo + } + interface SlotMap { // The 'root' entry itself is the runtime's built-in slot (declared // there); these four are the frame's children, declared by the same @@ -51,18 +60,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps } /** - * The whole center column, across both the no-session hero and a live - * conversation. OCCUPIED by ui-conversation's ConversationRoot, which - * declares the session body, composer, and input seats inside it — - * registering here replaces the entire conversation surface (and removes - * every seat it declares) rather than adding to it. - * - * Current-session-optional: the occupant owns both states without - * changing its React identity, so it keeps its own state across a session - * switch. It receives no owner props; session facts arrive through the - * framework hooks of the `session-maybe` scope. + * Central panel selected by sidebar entry id. The reserved `conversation` + * key hosts the Conversation; other keys receive no Session binding. */ - 'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps } + 'main': { kind: 'keyed'; scope: 'root' } /** * The right column: a track the centre makes room for, or nothing. OCCUPIED * by the right Sidebar, which uses the resolved column width in normal @@ -73,10 +74,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * occupant's own recorded business — it reports the composition of its * expanded and presentation state through `ctx.layout`, and the frame sizes * the track and places the resize handle from that. The expand control is - * not this column's: it is a button in the conversation header. With no - * current session nothing is mounted here. + * not this column's: it is a button in the conversation header. The root + * occupant decides when to render its Session-bound content. */ - 'rightbar': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps } + 'rightbar': { kind: 'single'; scope: 'root'; owner: RightbarOwnerProps } /** * Frame-wide floating layer, above every column and outside their scroll * containers. Deliberately generic and unowned by any feature: a badge, a @@ -105,9 +106,6 @@ export interface SidebarOwnerProps { width: number } -/** Conversation owner share: business state and actions belong to the registrant. */ -export interface ConvOwnerProps {} - /** Right column owner share: resolved normal geometry and opening eligibility. */ export interface RightbarOwnerProps { /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */ @@ -127,34 +125,44 @@ export const inject = ['slots', 'theme', 'locale'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame * into 'root' with the four child-slot declarations, the layout store seat, - * and the inject hook that hands the store's bound actions to the service. + * and the shared root instance supplying commands and the panel-info source. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const layout = new LayoutController() ctx.effect(() => { + const handle = createLayoutStore() + const instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + const layout = new LayoutController(instance.actions, id => + ctx.slots.entries('main').some(entry => entry.options.key === id)) + const retainMainPanels = (): void => { + instance.actions.retainMainPanels(ctx.slots.entries('main').flatMap(entry => + entry.options.key === undefined ? [] : [entry.options.key])) + } + const panelInfo: HostObservable = { + getSnapshot: () => instance.getSnapshot().panelInfo, + subscribe: listener => instance.subscribe(listener), + } + const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo } }) const disposeService = ctx.reflect.provide('layout', layout) const disposeRegistration = ctx.slots.register({ name: 'root', locale: 'common', children: { 'sidebar': { kind: 'single', scope: 'root' }, - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'rightbar': { kind: 'single', scope: 'session' }, + 'main': { kind: 'keyed', scope: 'root' }, + 'rightbar': { kind: 'single', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' }, }, - // Exclusive store: the factory itself — the framework instantiates per - // entry and delivers useStore/actions to AppFrame as standard props. - store: createLayoutStore, - // The hook's only side effect connects the root store to ctx.layout; - // conversation business actions belong to their registrants. - inject: (actions: PanelActions) => { - layout.attachPanels(actions) - return {} - }, + store, }, AppFrame) + const disposePanels = ctx.slots.subscribe('main', retainMainPanels) + retainMainPanels() return () => { + layout.dispose() + disposePanels() disposeRegistration() + disposePanelInfo() // provide()'s disposer settles asynchronously; teardown is synchronous fire-and-forget. void disposeService() } diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 46369f0346..14a3924d63 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -1,26 +1,42 @@ /** * LayoutController: the cross-plugin panel-action face behind ctx.layout. - * Panel geometry itself lives in the root entry's layout store (stores.ts); + * Panel geometry and main-panel selection live in the root layout store; * the current-session selection lives with the runtime sessions service, and * the per-session active view dissolved into ui-conversation's session store * (its only consumer). What remains here is the contract other plugins' - * apply worlds reach for panel transitions (sidebar toggle from ui-sidebar, + * apply worlds reach for panel transitions (main-panel selection and sidebar toggle, * right-panel show/hide from ui-sidebar-right) — writes stay inside the - * store's declared action set, delivered as the registration's bound actions. + * store's declared action set, shared with the root registration. */ import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { createLayoutStore } from './stores.ts' +/** Identity shared by a sidebar panel entry and its main-slot occupant. */ +export type MainPanelId = Branded<'MainPanelId'> + +/** Root-scoped navigation state exposed to panel-aware components. */ +export interface PanelInfo { + /** Selected global panel; null displays the current Conversation. */ + readonly activePanelId: MainPanelId | null +} + /** The layout store's bound action set (framework-baked, draft params peeled). */ export type PanelActions = BoundActions> -/** - * The outward layout face (`ctx.layout`): the panel transitions other - * plugins may trigger — and exactly what a test fake must supply. The - * attachPanels wiring hook stays on the concrete class (root-entry assembly - * only). - */ +/** Panel navigation and geometry actions exposed through ctx.layout. */ export interface ILayout { + /** + * Select a global central panel without changing the current Session. + * @param panelId - registered main key, or null to show the Conversation. + * @throws if the selected main key is not registered; preserves the current selection. + */ + selectPanel(panelId: MainPanelId | null): void + /** + * Start an asynchronous navigation, superseding any earlier pending navigation. + * @returns a signal aborted by the next navigation or layout disposal; check it before committing UI state. + */ + beginNavigation(): AbortSignal /** Toggle the sidebar panel (closed ⟷ contract default width). */ toggleSidebar(): void /** @@ -37,39 +53,50 @@ export interface ILayout { /** Cross-plugin panel-action face (ctx.layout). */ export class LayoutController implements ILayout { - #panels: PanelActions | undefined + private navigation = new AbortController() /** - * Adopt the root entry's bound store actions. Called from the root - * registration's inject hook (a sanctioned assembly side effect), so the - * face is live from the entry's first render; on entry re-register the - * fresh actions overwrite the stale set. - * @param actions - bound actions of the entry's layout store instance. + * @param panels - actions of the instance shared with the root entry. + * @param hasMainPanel - checks the live main-slot registry for a panel id. */ - attachPanels(actions: PanelActions): void { - this.#panels = actions + constructor( + private readonly panels: PanelActions, + private readonly hasMainPanel: (id: MainPanelId) => boolean, + ) {} + + /** Select a global panel or return to the Conversation. */ + selectPanel(panelId: MainPanelId | null): void { + if (panelId !== null && !this.hasMainPanel(panelId)) { + throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`) + } + this.navigation.abort() + this.panels.selectPanel(panelId) + } + + /** @returns the new pending navigation's cancellation signal. */ + beginNavigation(): AbortSignal { + this.navigation.abort() + this.navigation = new AbortController() + return this.navigation.signal + } + + /** Invalidate pending navigations when the layout owner is unloaded. */ + dispose(): void { + this.navigation.abort() } /** Toggle the sidebar panel (closed ⟷ contract default width). */ toggleSidebar(): void { - this.#require().toggleSidebar() + this.panels.toggleSidebar() } /** Report the right panel's track and fullscreen presentation. */ openRightbar(track: boolean, fullscreen: boolean): void { - this.#require().openRightbar(track, fullscreen) + this.panels.openRightbar(track, fullscreen) } /** Report the right panel as hidden: no track, no handle. */ closeRightbar(): void { - this.#require().closeRightbar() - } - - #require(): PanelActions { - // Callers are UI gestures, which cannot fire before the root entry - // rendered (the inject hook runs in its first render) — reaching this - // unwired is a boot-order bug, not a race to tolerate. - if (this.#panels === undefined) throw new Error('layout: panel actions not wired (root entry not mounted)') - return this.#panels + this.panels.closeRightbar() } } diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index 9191745a66..3a53caf4f1 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -3,6 +3,7 @@ * The registration supplies a fresh store and binds its actions to ctx.layout. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { MainPanelId } from './service.ts' import { clampWidth, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, @@ -13,6 +14,14 @@ import { * the right panel's expanded state belongs to its occupant. */ type LayoutState = { + panelInfo: { + /** Null selects the Conversation; global panels keep the current Session intact. */ + activePanelId: MainPanelId | null + } + layoutInfo: LayoutInfo +} + +type LayoutInfo = { sidebar: number /** Last positive frame measurement; window width bootstraps the first render. */ viewportWidth: number @@ -47,6 +56,8 @@ type LayoutState = { * return type); drift fails assignability at the defineStore call. */ type LayoutActions = { + selectPanel: (draft: LayoutState, panelId: MainPanelId | null) => void + retainMainPanels: (draft: LayoutState, panelIds: readonly string[]) => void setSidebar: (draft: LayoutState, px: number) => void toggleSidebar: (draft: LayoutState) => void setViewportWidth: (draft: LayoutState, width: number) => void @@ -67,56 +78,67 @@ type LayoutActions = { export function createLayoutStore(): EngineStoreHandle { const handle = defineStore({ init: (): LayoutState => ({ - sidebar: SIDEBAR_DEFAULT, - viewportWidth: window.innerWidth, - narrowExpanded: false, - rightbar: null, - rightbarShown: false, - rightbarTrack: false, - rightbarFullscreen: false, - rightbarInstant: false, + panelInfo: { activePanelId: null }, + layoutInfo: { + sidebar: SIDEBAR_DEFAULT, + viewportWidth: window.innerWidth, + narrowExpanded: false, + rightbar: null, + rightbarShown: false, + rightbarTrack: false, + rightbarFullscreen: false, + rightbarInstant: false, + }, }), actions: { + selectPanel: (d, panelId: MainPanelId | null) => { + d.panelInfo.activePanelId = panelId + }, + retainMainPanels: (d, panelIds: readonly string[]) => { + if (d.panelInfo.activePanelId !== null && !panelIds.includes(d.panelInfo.activePanelId)) { + d.panelInfo.activePanelId = null + } + }, setSidebar: (d, px: number) => { - d.rightbarInstant = false - d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) + d.layoutInfo.rightbarInstant = false + d.layoutInfo.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) }, // Narrow toggles flip only the override: the width preference survives // untouched, so re-widening restores the pre-squeeze layout. toggleSidebar: (d) => { - d.rightbarInstant = false - if (d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = !d.narrowExpanded - else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 + d.layoutInfo.rightbarInstant = false + if (d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = !d.layoutInfo.narrowExpanded + else d.layoutInfo.sidebar = d.layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : 0 }, // Crossing the breakpoint in either direction drops the override: the // narrow default is auto-collapsed, the wide state is the preference. setViewportWidth: (d, width: number) => { - if (d.viewportWidth === width) return - d.rightbarInstant = false - if ((d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) { - d.narrowExpanded = false + if (d.layoutInfo.viewportWidth === width) return + d.layoutInfo.rightbarInstant = false + if ((d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) { + d.layoutInfo.narrowExpanded = false } - d.viewportWidth = width + d.layoutInfo.viewportWidth = width }, setRightbar: (d, px: number) => { - d.rightbarInstant = false - d.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.viewportWidth * RIGHTBAR_MAX_RATIO)) + d.layoutInfo.rightbarInstant = false + d.layoutInfo.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.layoutInfo.viewportWidth * RIGHTBAR_MAX_RATIO)) }, openRightbar: (d, track: boolean, fullscreen: boolean) => { - if (!d.rightbarShown || d.rightbarTrack !== track || d.rightbarFullscreen !== fullscreen) { - d.rightbarInstant = d.rightbarFullscreen && !fullscreen + if (!d.layoutInfo.rightbarShown || d.layoutInfo.rightbarTrack !== track || d.layoutInfo.rightbarFullscreen !== fullscreen) { + d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen && !fullscreen } - if (!d.rightbarShown && d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = false - d.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.viewportWidth * RIGHTBAR_DEFAULT_RATIO)) - d.rightbarShown = true - d.rightbarTrack = track - d.rightbarFullscreen = fullscreen + if (!d.layoutInfo.rightbarShown && d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = false + d.layoutInfo.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.layoutInfo.viewportWidth * RIGHTBAR_DEFAULT_RATIO)) + d.layoutInfo.rightbarShown = true + d.layoutInfo.rightbarTrack = track + d.layoutInfo.rightbarFullscreen = fullscreen }, closeRightbar: (d) => { - if (d.rightbarShown) d.rightbarInstant = d.rightbarFullscreen - d.rightbarShown = false - d.rightbarTrack = false - d.rightbarFullscreen = false + if (d.layoutInfo.rightbarShown) d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen + d.layoutInfo.rightbarShown = false + d.layoutInfo.rightbarTrack = false + d.layoutInfo.rightbarFullscreen = false }, }, }) diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index 62623b9b81..2e525ae028 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -1,26 +1,23 @@ // @vitest-environment jsdom /** Frame interactions with a real store and explicitly driven browser measurements. */ -import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' -import { useSyncExternalStore } from 'react' import { AppFrame } from '../src/client/AppFrame.tsx' import type { AppFrameProps } from '../src/client/AppFrame.tsx' -import type { RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts' +import type { MainPanelId, RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts' import { createLayoutStore } from '../src/client/stores.ts' -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] let selectedSession: SessionId | undefined let selectedSessionTitle: string | undefined let workspacesReady = true type AttentionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention) -const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) => - selectedSession === undefined ? <>{empty?.() ?? null} : <>{children} let observers: ResizeObserverStub[] class ResizeObserverStub { @@ -63,19 +60,15 @@ function resize(width: number): void { }) } -function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { - return function useSelector(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) } -} - function mountFrame(windowWidth = frameWidth) { vi.stubGlobal('innerWidth', windowWidth) const instance = createLayoutStore().create() - const slotCalls: { key: string; props: object }[] = [] - const renderSlot = ((key: string, owner: object) => { - slotCalls.push({ key, props: owner }) - return
- }) as AppFrameProps['renderSlot'] - const useSessions = ((sel: (s: SessionListState) => unknown) => sel({ + const slotCalls: { key: string; props: object; options: RenderOpts | undefined }[] = [] + const renderSlot: AppFrameProps['renderSlot'] = (key, owner, options) => { + slotCalls.push({ key, props: owner, options }) + return
+ } + const useSessions: AppFrameProps['useSessions'] = sel => sel({ ids: selectedSession === undefined ? [] : [selectedSession], byId: selectedSession === undefined ? {} : { [selectedSession]: { @@ -85,22 +78,29 @@ function mountFrame(windowWidth = frameWidth) { }, current: selectedSession, phase: 'ready', - } as SessionListState)) as AppFrameProps['useSessions'] + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) const workspaceState: WorkspaceSnapshot = { items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, ...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }), } - const useStore = hookOf(instance) + const useStore = bindSnapshotSelector(instance) + const usePanelInfo = bindSnapshotSelector({ + getSnapshot: () => instance.getSnapshot().panelInfo, + subscribe: listener => instance.subscribe(listener), + }) const element = () => ( unknown) => sel(workspaceState)) as AppFrameProps['useWorkspaces']} - SessionProvider={SessionProviderStub} + useWorkspaces={sel => sel(workspaceState)} t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key} /> ) @@ -202,22 +202,22 @@ describe('AppFrame', () => { expect(tracks(frame)).toEqual([280, 0]) expect(sidebarOwner()).toEqual({ collapsed: false, width: 280 }) expect(rightOwner()).toEqual({ width: 864, viewportWidth: 1920, canShow: true }) - expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({}) + expect(slotCalls.find(c => c.key === 'main')).toEqual({ key: 'main', props: {}, options: { entryKey: 'conversation' } }) }) - it('retains conversation and sidebar content without a current Session', () => { + it('renders the main, sidebar, and root-scoped rightbar outlets without a current Session', () => { selectedSession = undefined - const { frame, getByTestId, queryByTestId } = mountFrame() - expect(getByTestId('conversation-content')).toBeTruthy() + const { frame, getByTestId } = mountFrame() + expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation') expect(getByTestId('sidebar-content')).toBeTruthy() - expect(queryByTestId('rightbar-content')).toBeNull() + expect(getByTestId('rightbar-content')).toBeTruthy() expect(tracks(frame)).toEqual([280, 0]) }) it('renders both occupants before workspace baselines settle', () => { workspacesReady = false const { getByTestId } = mountFrame() - expect(getByTestId('conversation-content')).toBeTruthy() + expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation') expect(getByTestId('rightbar-content')).toBeTruthy() }) @@ -229,16 +229,34 @@ describe('AppFrame', () => { expect(getByTestId('sidebar-content')).toBeTruthy() expect(frame.querySelector('[data-side="sidebar"]')).toBeNull() }) + + it('switches only the keyed main outlet when the active panel changes', () => { + selectedSessionTitle = 'Session title' + const { instance, frame, slotCalls, getByTestId } = mountFrame() + const sessionId = selectedSession + const layoutInfo = instance.getSnapshot().layoutInfo + for (const panelId of ['panel-a' as MainPanelId, 'panel-b' as MainPanelId, null]) { + slotCalls.length = 0 + act(() => { instance.actions.selectPanel(panelId) }) + expect(slotCalls).toEqual([{ key: 'main', props: {}, options: { entryKey: panelId ?? 'conversation' } }]) + expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe(panelId ?? 'conversation') + expect(instance.getSnapshot().panelInfo).toEqual({ activePanelId: panelId }) + expect(instance.getSnapshot().layoutInfo).toBe(layoutInfo) + expect(tracks(frame)).toEqual([280, 0]) + expect(selectedSession).toBe(sessionId) + expect(document.title).toBe(panelId === null ? 'Session title — DSH Local Build' : 'DSH Local Build') + } + }) }) describe('AppFrame normal width concessions', () => { it('measures the frame, not the window, before choosing the first-open preference', () => { frameWidth = 1000 const { instance, rightOwner } = mountFrame(1920) - expect(instance.getSnapshot().viewportWidth).toBe(1000) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1000) expect(rightOwner()).toEqual({ width: 450, viewportWidth: 1000, canShow: true }) act(() => { instance.actions.openRightbar(true, false) }) - expect(instance.getSnapshot().rightbar).toBe(450) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(450) resize(1920) expect(rightOwner().width).toBe(450) }) @@ -255,7 +273,7 @@ describe('AppFrame normal width concessions', () => { expect(tracks(frame)).toEqual([420, 0]) expect(rightOwner()).toEqual({ width: 0, viewportWidth: 1119, canShow: false }) expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() - expect(instance.getSnapshot()).toMatchObject({ rightbarShown: true, rightbar: 864 }) + expect(instance.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbar: 864 }) act(() => { instance.actions.closeRightbar() }) resize(455) expect(tracks(frame)).toEqual([56, 0]) @@ -271,7 +289,7 @@ describe('AppFrame normal width concessions', () => { expect(rightOwner()).toEqual({ width: 344, viewportWidth: 800, canShow: true }) act(() => { instance.actions.openRightbar(true, false) }) expect(tracks(frame)).toEqual([56, 344]) - expect(instance.getSnapshot()).toMatchObject({ narrowExpanded: false, rightbar: 360 }) + expect(instance.getSnapshot().layoutInfo).toMatchObject({ narrowExpanded: false, rightbar: 360 }) expect(rightOwner().canShow).toBe(true) }) @@ -312,7 +330,7 @@ describe('AppFrame normal width concessions', () => { resize(980) act(() => { instance.actions.toggleSidebar() }) expect(tracks(frame)[0]).toBe(280) - expect(instance.getSnapshot().sidebar).toBe(0) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) }) }) @@ -404,7 +422,7 @@ describe('AppFrame right panel presentation', () => { act(() => { instance.actions.openRightbar(false, true) }) expect(tracks(frame)).toEqual([56, 0]) expect(rightOwner()).toEqual({ width: 0, viewportWidth: 700, canShow: false }) - expect(instance.getSnapshot().rightbarShown).toBe(true) + expect(instance.getSnapshot().layoutInfo.rightbarShown).toBe(true) expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() }) @@ -415,7 +433,7 @@ describe('AppFrame right panel presentation', () => { expect(tracks(frame)).toEqual([280, 0]) expect(rightOwner().width).toBe(420) drag(handleFor(frame, 'rightbar'), 680, 690) - expect(instance.getSnapshot().rightbar).toBe(410) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410) expect(rightOwner().width).toBe(410) expect(tracks(frame)[1]).toBe(0) }) @@ -436,7 +454,7 @@ describe('AppFrame pointer resizing', () => { act(flushFrames) expect(tracks(frame)[0]).toBe(360) pointer(handle, 'pointerup', 360) - expect(instance.getSnapshot().sidebar).toBe(360) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360) expect(frame.dataset.dragging).toBeUndefined() expect(handle.hasPointerCapture(1)).toBe(false) }) @@ -450,7 +468,7 @@ describe('AppFrame pointer resizing', () => { expect(tracks(frame)[1]).toBe(420) expect(handle.style.left).toBe('680px') drag(handle, 680, 690) - expect(instance.getSnapshot().rightbar).toBe(410) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410) expect(rightOwner().width).toBe(410) expect(tracks(frame)[1]).toBe(410) expect(handle.style.left).toBe('690px') @@ -474,10 +492,10 @@ describe('AppFrame pointer resizing', () => { pointer(handle, 'pointerdown', 280) pointer(handle, 'pointermove', 320) pointer(handle, 'pointerup', 360) - expect(instance.getSnapshot().sidebar).toBe(360) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360) expect(animationFrames.size).toBe(0) act(flushFrames) - expect(instance.getSnapshot().sidebar).toBe(360) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360) }) it('ignores uncaptured motion, secondary buttons, and a second pointer', () => { @@ -493,9 +511,9 @@ describe('AppFrame pointer resizing', () => { pointer(handle, 'pointermove', 500, 9) pointer(handle, 'pointerup', 500, 9) expect(animationFrames.size).toBe(0) - expect(instance.getSnapshot().sidebar).toBe(280) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280) pointer(handle, 'pointerup', 300) - expect(instance.getSnapshot().sidebar).toBe(300) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(300) }) it.each(['pointercancel', 'lostpointercapture'])('ends %s without committing queued motion', (event) => { @@ -506,7 +524,7 @@ describe('AppFrame pointer resizing', () => { if (event === 'lostpointercapture') handle.releasePointerCapture(1) pointer(handle, event, 340) act(flushFrames) - expect(instance.getSnapshot().sidebar).toBe(280) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280) expect(animationFrames.size).toBe(0) expect(frame.dataset.dragging).toBeUndefined() expect(handle.hasPointerCapture(1)).toBe(false) @@ -526,7 +544,7 @@ describe('AppFrame pointer resizing', () => { const settled = instance.getSnapshot() act(flushFrames) expect(instance.getSnapshot()).toBe(settled) - expect(instance.getSnapshot().rightbar).toBe(864) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(864) expect(animationFrames.size).toBe(0) expect(handle.hasPointerCapture(1)).toBe(false) if (change !== 'unmount') expect(frame.dataset.dragging).toBeUndefined() @@ -544,17 +562,17 @@ describe('AppFrame frame measurement lifecycle', () => { observer.fire() }) expect(animationFrames.size).toBe(1) - expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920) act(flushFrames) - expect(instance.getSnapshot().viewportWidth).toBe(1200) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1200) expect(rightOwner().viewportWidth).toBe(1200) - expect(instance.getSnapshot().rightbar).toBeNull() + expect(instance.getSnapshot().layoutInfo.rightbar).toBeNull() }) it('retains the last positive measurement while the frame is hidden', () => { const { instance, rightOwner } = mountFrame() resize(0) - expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920) expect(rightOwner().viewportWidth).toBe(1920) }) @@ -568,7 +586,7 @@ describe('AppFrame frame measurement lifecycle', () => { expect(observer.disconnected).toBe(true) expect(animationFrames.size).toBe(0) act(() => { observer.fire(); flushFrames() }) - expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920) expect(animationFrames.size).toBe(0) }) }) diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 38732eda2c..74c58022c9 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -1,20 +1,58 @@ // @vitest-environment jsdom -import { Context } from '@deepseek-ai/cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' +import type { MainPanelId } from '../src/client/service.ts' +import type { createLayoutStore } from '../src/client/stores.ts' + +const owners = new Set() +let originalRootStyle: string | null +let originalBodyStyle: string | null +let originalDarkTheme: string | null +let originalThemeMetadata: Element[] beforeEach(() => { - document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) + originalRootStyle = document.documentElement.getAttribute('style') + originalBodyStyle = document.body.getAttribute('style') + originalDarkTheme = document.body.getAttribute('data-ds-dark-theme') + originalThemeMetadata = [...document.head.querySelectorAll('meta[name="theme-color"]')] + originalThemeMetadata.forEach((node) => { node.remove() }) + vi.stubGlobal('innerWidth', 1920) }) +afterEach(async () => { + try { + for (const owner of owners) await owner.dispose() + } finally { + owners.clear() + restoreAttribute(document.documentElement, 'style', originalRootStyle) + restoreAttribute(document.body, 'style', originalBodyStyle) + restoreAttribute(document.body, 'data-ds-dark-theme', originalDarkTheme) + document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) + document.head.append(...originalThemeMetadata) + vi.unstubAllGlobals() + } +}) + +function restoreAttribute(element: Element, name: string, value: string | null): void { + if (value === null) element.removeAttribute(name) + else element.setAttribute(name, value) +} + async function bench() { - const ctx = new Context() + const root = new Context() + let ctx: Context | undefined + const owner = root.plugin((ownedContext: Context) => { ctx = ownedContext }) + owners.add(owner) + await owner.await() + if (ctx === undefined) throw new Error('the fixture owner did not activate') const slotsFiber = ctx.plugin(SlotRegistry) // Theme registers its Appearance settings row and requires the connection // seam for persistence; model this bench as a remote, memory-only browser. @@ -25,7 +63,15 @@ async function bench() { ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() - return { ctx, slots: ctx.get('slots') as SlotRegistry } + const slots = ctx.get('slots') as SlotRegistry + let host: SlotRendererHost | undefined + slots.install({ renderRoot: (value) => { host = value; return null } }) + const rendererHost = (): SlotRendererHost => { + slots.renderSlot('root', {}) + if (host === undefined) throw new Error('the root renderer did not receive its host') + return host + } + return { ctx, slots, rendererHost } } describe('ui-layout client apply', () => { @@ -33,30 +79,46 @@ describe('ui-layout client apply', () => { expect(inject).toEqual(['slots', 'theme', 'locale']) }) - it('provides ctx.layout and registers AppFrame into root with the three child declarations', async () => { + it('provides ctx.layout and declares the four root-scoped frame slots', async () => { const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() expect(ctx.get('layout')).toBeInstanceOf(LayoutController) - // The one register() call occupied 'root'… expect(slots.entries('root')).toHaveLength(1) - // …and declared the three children in the ledger. expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' }) - expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session-maybe' }) + expect(slots.spec('main')).toEqual({ kind: 'keyed', scope: 'root' }) + expect(slots.spec('rightbar')).toEqual({ kind: 'single', scope: 'root' }) + expect(slots.spec('shell.overlay')).toEqual({ kind: 'list', scope: 'root' }) }) - it('injects no business face and attaches the layout actions', async () => { - const { ctx, slots } = await bench() + it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => { + const { ctx, slots, rendererHost } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - const actions = { - setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(), - } - const injected = (slots.entries('root')[0]!.inject as (actions: never) => object)(actions as never) - expect(injected).toEqual({}) + const entry = slots.entries('root')[0]! + expect(entry.inject).toBeUndefined() + const handle = entry.store as ReturnType + const instance = handle.create() + expect(handle.create()).toBe(instance) const layout = ctx.get('layout') as LayoutController + expect(() => { layout.selectPanel('missing' as MainPanelId) }).toThrow('main panel "missing" is not registered') layout.toggleSidebar() - expect(actions.toggleSidebar).toHaveBeenCalledOnce() + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) + const host = rendererHost() + expect(host.storeOf(entry, undefined)).toBe(instance) + const panelInfo = host.root.getSnapshot().hooks.panelInfo! + expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo) + const panelId = 'panel-a' as MainPanelId + const disposePanel = slots.register({ name: 'main', key: panelId }, () => null) + layout.selectPanel(panelId) + expect(panelInfo.getSnapshot()).toEqual({ activePanelId: panelId }) + disposePanel() + await vi.waitFor(() => { expect(panelInfo.getSnapshot()).toEqual({ activePanelId: null }) }) + expect(() => { layout.selectPanel(panelId) }).toThrow('main panel "panel-a" is not registered') + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) + const pending = layout.beginNavigation() + await fiber.dispose() + expect(pending.aborted).toBe(true) }) it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => { @@ -85,13 +147,19 @@ describe('ui-layout client apply', () => { }) it('teardown unwinds the service, the root registration, and the child declarations', async () => { - const { ctx, slots } = await bench() + const { ctx, slots, rendererHost } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() + const host = rendererHost() + expect(host.root.getSnapshot().hooks.panelInfo).toBeDefined() await fiber.dispose() expect(ctx.get('layout')).toBeUndefined() expect(slots.entries('root')).toHaveLength(0) expect(slots.spec('sidebar')).toBeUndefined() + expect(slots.spec('main')).toBeUndefined() + expect(slots.spec('rightbar')).toBeUndefined() + expect(slots.spec('shell.overlay')).toBeUndefined() + expect(host.root.getSnapshot().hooks.panelInfo).toBeUndefined() // The built-in root declaration survives entry teardown (renderer-owned). expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' }) }) diff --git a/packages/client/ui-layout/tests/document-title.client.spec.tsx b/packages/client/ui-layout/tests/document-title.client.spec.tsx index 2cec953cb1..5c3184970f 100644 --- a/packages/client/ui-layout/tests/document-title.client.spec.tsx +++ b/packages/client/ui-layout/tests/document-title.client.spec.tsx @@ -1,36 +1,82 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { DocumentTitle } from '../src/client/DocumentTitle.tsx' +import type { MainPanelId, PanelInfo } from '../src/client/service.ts' +let originalTitle: string +beforeEach(() => { originalTitle = document.title }) afterEach(() => { - cleanup() - document.title = '' - vi.unstubAllEnvs() + try { cleanup() } finally { document.title = originalTitle } }) +function titleSources() { + const sessionId = 'session-title' as SessionId + const sessions = createSnapshotStore({ + ids: [sessionId], + byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } }, + current: sessionId, + phase: 'ready', + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) + const panelInfo = createSnapshotStore({ activePanelId: null }) + return { + sessionId, sessions, panelInfo, + props: { useSessions: bindSnapshotSelector(sessions), usePanelInfo: bindSnapshotSelector(panelInfo) }, + } +} + describe('DocumentTitle', () => { it('projects a durable title and restores the product title', () => { - vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness') + const { sessionId, sessions, props } = titleSources() document.title = 'stale title' - const mounted = render() + const mounted = render() expect(document.title).toBe('DeepSeek Harness') - mounted.rerender() + act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'First title' }) }) expect(document.title).toBe('First title — DeepSeek Harness') - mounted.rerender() + act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Revised title' }) }) expect(document.title).toBe('Revised title — DeepSeek Harness') - mounted.rerender() + act(() => { sessions.update((state) => { state.current = undefined }) }) expect(document.title).toBe('DeepSeek Harness') mounted.unmount() expect(document.title).toBe('DeepSeek Harness') }) - it('uses the generic title when the build provides no title', () => { - vi.stubEnv('DSH_CLIENT_TITLE', '') - delete process.env.DSH_CLIENT_TITLE - const mounted = render() + it('uses the localized product title supplied by the frame', () => { + const { sessionId, sessions, props } = titleSources() + sessions.update((state) => { state.byId[sessionId]!.title = 'First title' }) + const mounted = render() expect(document.title).toBe('First title — DSH Local Build') mounted.unmount() expect(document.title).toBe('DSH Local Build') }) + + it('keeps the product title across global panels and restores the latest Session title on return', () => { + const { sessionId, sessions, panelInfo, props } = titleSources() + sessions.update((state) => { state.byId[sessionId]!.title = 'Session title' }) + render() + expect(document.title).toBe('Session title — Product') + act(() => { panelInfo.set({ activePanelId: 'panel-a' as MainPanelId }) }) + expect(document.title).toBe('Product') + act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Updated title' }) }) + expect(document.title).toBe('Product') + act(() => { panelInfo.set({ activePanelId: 'panel-b' as MainPanelId }) }) + expect(document.title).toBe('Product') + expect(sessions.getSnapshot().current).toBe(sessionId) + act(() => { panelInfo.set({ activePanelId: null }) }) + expect(document.title).toBe('Updated title — Product') + }) + + it('uses the product title when the current Session row is not available', () => { + const { sessions, props } = titleSources() + sessions.update((state) => { state.byId = {}; state.ids = [] }) + render() + expect(document.title).toBe('Product') + }) }) diff --git a/packages/client/ui-layout/tests/layout-store.client.spec.ts b/packages/client/ui-layout/tests/layout-store.client.spec.ts index 60dacf2f4b..edf2a5a3c9 100644 --- a/packages/client/ui-layout/tests/layout-store.client.spec.ts +++ b/packages/client/ui-layout/tests/layout-store.client.spec.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createLayoutStore } from '../src/client/stores.ts' +import type { MainPanelId } from '../src/client/service.ts' beforeEach(() => { vi.stubGlobal('innerWidth', 1920) }) afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) @@ -10,14 +11,17 @@ describe('createLayoutStore', () => { it('starts with the default sidebar and no right panel preference', () => { const { store } = createLayoutStore().create() expect(store.getSnapshot()).toEqual({ - sidebar: 280, - viewportWidth: 1920, - narrowExpanded: false, - rightbar: null, - rightbarShown: false, - rightbarTrack: false, - rightbarFullscreen: false, - rightbarInstant: false, + panelInfo: { activePanelId: null }, + layoutInfo: { + sidebar: 280, + viewportWidth: 1920, + narrowExpanded: false, + rightbar: null, + rightbarShown: false, + rightbarTrack: false, + rightbarFullscreen: false, + rightbarInstant: false, + }, }) }) @@ -27,26 +31,26 @@ describe('createLayoutStore', () => { const b = createLayoutStore().create() a.actions.setSidebar(400) a.actions.openRightbar(true, false) - expect(b.store.getSnapshot().sidebar).toBe(280) - expect(b.store.getSnapshot().rightbar).toBeNull() + expect(b.store.getSnapshot().layoutInfo.sidebar).toBe(280) + expect(b.store.getSnapshot().layoutInfo.rightbar).toBeNull() expect(write).not.toHaveBeenCalled() }) it('clamps the sidebar to 264–420px', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(1) - expect(store.getSnapshot().sidebar).toBe(264) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(264) actions.setSidebar(9999) - expect(store.getSnapshot().sidebar).toBe(420) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(420) }) it('toggles the wide sidebar between closed and default width', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(400) actions.toggleSidebar() - expect(store.getSnapshot().sidebar).toBe(0) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(0) actions.toggleSidebar() - expect(store.getSnapshot().sidebar).toBe(280) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(280) }) it('keeps the sidebar preference while toggling its narrow override', () => { @@ -54,9 +58,9 @@ describe('createLayoutStore', () => { actions.setSidebar(400) actions.setViewportWidth(980) actions.toggleSidebar() - expect(store.getSnapshot()).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true }) actions.toggleSidebar() - expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false }) }) it('clears the manual override only when crossing 1024px', () => { @@ -65,39 +69,101 @@ describe('createLayoutStore', () => { actions.toggleSidebar() actions.setViewportWidth(980) actions.setViewportWidth(1023) - expect(store.getSnapshot().narrowExpanded).toBe(true) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true) actions.setViewportWidth(1024) - expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false) actions.setViewportWidth(980) - expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false) }) }) +describe('main panel selection', () => { + const panelA = 'panel-a' as MainPanelId + const panelB = 'panel-b' as MainPanelId + + it('changes only panelInfo when switching panels and returning to the Conversation', () => { + const { store, actions } = createLayoutStore().create() + actions.setSidebar(400) + actions.openRightbar(true, true) + actions.closeRightbar() + const layoutInfo = store.getSnapshot().layoutInfo + for (const activePanelId of [panelA, panelB, null]) { + const previousPanelInfo = store.getSnapshot().panelInfo + actions.selectPanel(activePanelId) + expect(store.getSnapshot().panelInfo).toEqual({ activePanelId }) + expect(store.getSnapshot().panelInfo).not.toBe(previousPanelInfo) + expect(store.getSnapshot().layoutInfo).toBe(layoutInfo) + } + }) + + it('keeps the complete snapshot when selecting the current panel again', () => { + const { store, actions } = createLayoutStore().create() + actions.selectPanel(panelA) + const selected = store.getSnapshot() + actions.selectPanel(panelA) + expect(store.getSnapshot()).toBe(selected) + expect(store.getSnapshot().panelInfo.activePanelId).toBe(panelA) + }) + + it('returns to the Conversation only when the selected main registration disappears', () => { + const { store, actions } = createLayoutStore().create() + const initial = store.getSnapshot() + actions.retainMainPanels([]) + expect(store.getSnapshot()).toBe(initial) + actions.selectPanel(panelA) + const selected = store.getSnapshot() + actions.retainMainPanels(['conversation', panelA, panelB]) + expect(store.getSnapshot()).toBe(selected) + actions.retainMainPanels(['conversation', panelB]) + expect(store.getSnapshot().panelInfo).toEqual({ activePanelId: null }) + expect(store.getSnapshot().layoutInfo).toBe(selected.layoutInfo) + }) + + it.each(['setSidebar', 'toggleSidebar', 'setViewportWidth', 'setRightbar', 'openRightbar', 'closeRightbar'] as const)( + 'preserves panelInfo identity when %s changes layoutInfo', (action) => { + const { store, actions } = createLayoutStore().create() + actions.selectPanel(panelA) + if (action === 'closeRightbar') actions.openRightbar(true, true) + const previous = store.getSnapshot() + switch (action) { + case 'setSidebar': actions.setSidebar(400); break + case 'toggleSidebar': actions.toggleSidebar(); break + case 'setViewportWidth': actions.setViewportWidth(980); break + case 'setRightbar': actions.setRightbar(500); break + case 'openRightbar': actions.openRightbar(true, true); break + case 'closeRightbar': actions.closeRightbar(); break + } + expect(store.getSnapshot().panelInfo).toBe(previous.panelInfo) + expect(store.getSnapshot().layoutInfo).not.toBe(previous.layoutInfo) + }, + ) +}) + describe('right panel', () => { it('initializes at 45% of the latest frame only on first opening', () => { const { store, actions } = createLayoutStore().create() actions.setViewportWidth(1000) - expect(store.getSnapshot().rightbar).toBeNull() + expect(store.getSnapshot().layoutInfo.rightbar).toBeNull() actions.openRightbar(true, false) - expect(store.getSnapshot().rightbar).toBe(450) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(450) actions.setViewportWidth(2000) actions.openRightbar(true, true) - expect(store.getSnapshot().rightbar).toBe(450) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(450) actions.closeRightbar() actions.openRightbar(true, false) - expect(store.getSnapshot().rightbar).toBe(450) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(450) }) it('keeps track and fullscreen reports independent and clears both on close', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, false) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false }) actions.openRightbar(true, true) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true }) actions.openRightbar(false, true) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true }) actions.closeRightbar() - expect(store.getSnapshot()).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false }) }) it('keeps dragged px preferences across resize, close, and reopen', () => { @@ -105,29 +171,29 @@ describe('right panel', () => { actions.openRightbar(true, false) actions.setRightbar(1100) actions.setViewportWidth(800) - expect(store.getSnapshot().rightbar).toBe(1100) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100) actions.closeRightbar() actions.openRightbar(false, true) - expect(store.getSnapshot().rightbar).toBe(1100) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100) }) it('clamps drag preferences to 300px and 70% of the current frame', () => { const { store, actions } = createLayoutStore().create() actions.setViewportWidth(1600) actions.setRightbar(9999) - expect(store.getSnapshot().rightbar).toBe(1120) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(1120) actions.setViewportWidth(1000) actions.setRightbar(9999) - expect(store.getSnapshot().rightbar).toBe(700) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(700) actions.setRightbar(1) - expect(store.getSnapshot().rightbar).toBe(300) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(300) }) it('retains a minimum normal preference when first opened fullscreen on a phone', () => { const { store, actions } = createLayoutStore().create() actions.setViewportWidth(320) actions.openRightbar(false, true) - expect(store.getSnapshot().rightbar).toBe(300) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(300) }) it('collapses a manually expanded narrow sidebar on opening, not presentation reports', () => { @@ -136,23 +202,23 @@ describe('right panel', () => { actions.setViewportWidth(800) actions.toggleSidebar() actions.openRightbar(true, false) - expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false }) actions.toggleSidebar() actions.openRightbar(true, true) - expect(store.getSnapshot().narrowExpanded).toBe(true) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true) actions.closeRightbar() actions.openRightbar(true, false) - expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false) }) it('keeps the wide sidebar preference and never opens a closed right panel on resize', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(420) actions.openRightbar(true, false) - expect(store.getSnapshot().sidebar).toBe(420) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(420) actions.closeRightbar() actions.setViewportWidth(3000) - expect(store.getSnapshot()).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false }) }) }) @@ -161,7 +227,7 @@ describe('right panel instant geometry', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(track, true) actions.closeRightbar() - expect(store.getSnapshot()).toMatchObject({ + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false, rightbarInstant: true, }) const closed = store.getSnapshot() @@ -173,38 +239,38 @@ describe('right panel instant geometry', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, true) actions.openRightbar(true, false) - expect(store.getSnapshot()).toMatchObject({ + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false, rightbarInstant: true, }) const restored = store.getSnapshot() actions.openRightbar(true, false) expect(store.getSnapshot()).toBe(restored) actions.openRightbar(false, false) - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) }) it('allows a normal close to animate, including after restoring from fullscreen', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, false) actions.closeRightbar() - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) actions.openRightbar(true, true) actions.openRightbar(true, false) - expect(store.getSnapshot().rightbarInstant).toBe(true) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true) actions.closeRightbar() - expect(store.getSnapshot()).toMatchObject({ rightbarTrack: false, rightbarInstant: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarTrack: false, rightbarInstant: false }) }) it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'setViewportWidth'] as const)('clears instant geometry on %s', (action) => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, true) actions.closeRightbar() - expect(store.getSnapshot().rightbarInstant).toBe(true) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true) if (action === 'toggleSidebar') actions.toggleSidebar() else actions[action](action === 'setViewportWidth' ? 1800 : 350) - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) actions.closeRightbar() - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) }) it('does not let an unchanged frame measurement reset the fullscreen-exit marker', () => { @@ -212,7 +278,7 @@ describe('right panel instant geometry', () => { actions.openRightbar(true, true) actions.closeRightbar() const closed = store.getSnapshot() - actions.setViewportWidth(closed.viewportWidth) + actions.setViewportWidth(closed.layoutInfo.viewportWidth) expect(store.getSnapshot()).toBe(closed) }) @@ -221,6 +287,6 @@ describe('right panel instant geometry', () => { actions.openRightbar(true, true) actions.closeRightbar() actions.openRightbar(true, fullscreen) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false }) }) }) diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index a95c6a5b44..7ccadb5c3c 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { LayoutController } from '../src/client/service.ts' -import type { PanelActions } from '../src/client/service.ts' +import type { MainPanelId, PanelActions } from '../src/client/service.ts' function fakePanels(): PanelActions { return { + selectPanel: vi.fn(), + retainMainPanels: vi.fn(), setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), @@ -14,10 +16,9 @@ function fakePanels(): PanelActions { } describe('LayoutController', () => { - it('forwards the right column transitions to the attached set', () => { - const service = new LayoutController() + it('forwards right column transitions to the constructor-supplied actions', () => { const panels = fakePanels() - service.attachPanels(panels) + const service = new LayoutController(panels, () => true) service.openRightbar(true, false) service.openRightbar(true, true) @@ -32,10 +33,9 @@ describe('LayoutController', () => { expect(panels.setRightbar).not.toHaveBeenCalled() }) - it('forwards the three panel actions to the attached set', () => { - const service = new LayoutController() + it('can toggle the sidebar immediately after construction', () => { const panels = fakePanels() - service.attachPanels(panels) + const service = new LayoutController(panels, () => true) service.toggleSidebar() @@ -43,21 +43,62 @@ describe('LayoutController', () => { expect(panels.setSidebar).not.toHaveBeenCalled() }) - it('fails loud before the root entry wired its actions', () => { - const service = new LayoutController() - expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/) + it('forwards panel selection and returning to the Conversation without changing geometry', () => { + const panels = fakePanels() + const service = new LayoutController(panels, () => true) + const panelId = 'panel-a' as MainPanelId + service.selectPanel(panelId) + service.selectPanel(panelId) + service.selectPanel(null) + expect(panels.selectPanel).toHaveBeenNthCalledWith(1, panelId) + expect(panels.selectPanel).toHaveBeenNthCalledWith(2, panelId) + expect(panels.selectPanel).toHaveBeenNthCalledWith(3, null) + expect(panels.toggleSidebar).not.toHaveBeenCalled() + expect(panels.openRightbar).not.toHaveBeenCalled() + expect(panels.closeRightbar).not.toHaveBeenCalled() }) - it('re-attach overwrites the stale action set (entry re-register)', () => { - const service = new LayoutController() - const stale = fakePanels() - const fresh = fakePanels() - service.attachPanels(stale) - service.attachPanels(fresh) + it('keeps separately constructed controllers bound to their own instances', () => { + const first = fakePanels() + const second = fakePanels() + const firstService = new LayoutController(first, () => true) + const secondService = new LayoutController(second, () => true) + firstService.toggleSidebar() + expect(first.toggleSidebar).toHaveBeenCalledTimes(1) + expect(second.toggleSidebar).not.toHaveBeenCalled() + secondService.closeRightbar() + expect(first.closeRightbar).not.toHaveBeenCalled() + expect(second.closeRightbar).toHaveBeenCalledTimes(1) + }) - service.toggleSidebar() + it('rejects an absent main entry without changing selection or cancelling pending navigation', () => { + const panels = fakePanels() + const present = new Set(['panel-a']) + const service = new LayoutController(panels, id => present.has(id)) + service.selectPanel('panel-a' as MainPanelId) + const navigation = service.beginNavigation() + present.delete('panel-a') + expect(() => { service.selectPanel('panel-a' as MainPanelId) }).toThrow('main panel "panel-a" is not registered') + expect(panels.selectPanel).toHaveBeenCalledOnce() + expect(navigation.aborted).toBe(false) + service.selectPanel(null) + expect(navigation.aborted).toBe(true) + }) - expect(stale.toggleSidebar).not.toHaveBeenCalled() - expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1) + it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => { + const service = new LayoutController(fakePanels(), () => true) + const first = service.beginNavigation() + const second = service.beginNavigation() + expect(first.aborted).toBe(true) + expect(second.aborted).toBe(false) + service.selectPanel('panel-a' as MainPanelId) + expect(second.aborted).toBe(true) + const repeated = service.beginNavigation() + service.selectPanel('panel-a' as MainPanelId) + expect(repeated.aborted).toBe(true) + const pending = service.beginNavigation() + service.dispose() + expect(pending.aborted).toBe(true) + service.dispose() }) }) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 79a7f1ce54..ed6a3944d2 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index da92ab50d8..b73978ed20 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index ac7104234d..d3c89480e8 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -63,14 +63,12 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "clsx": "^2.1.1" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ], - "dependencies": { - "clsx": "^2.1.1" - } + ] } diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 7565ef82a6..3b39d1e5d8 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-open-in-app", "description": "Web Session-header \"Open In...\" split button opening the session workspace directory in a locally installed application", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 75fc145050..e065186bcd 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 5ef380f656..b1af331463 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -13,6 +13,7 @@ import { PermissionPresetSettingsController } from '../src/client/settings-store // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) const schema = new SettingsSchemaService(new Context()) @@ -60,7 +61,7 @@ const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInterac const runtime = { useSessions: (() => { throw new Error('unused') }) as never, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: (() => { throw new Error('unused') }) as never, } diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index ec429207af..7080b89644 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 4bda8cc5f7..4f9173f729 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 18b136c78539d8ba6da3b17239112967faf63602 -README.zh.md: f91435e44a5ca79b245fde3dd025a732d35e54e9 +README.md: 7b670394f6ca70ea369fc79766b26119f670f753 +README.zh.md: 8ddefde928a723992945f801fb7407a2d22f5f27 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 18b136c785..7b670394f6 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -25,6 +25,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out ## Use this package +This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). + Compose feature UI from these atoms whenever the web client needs a standard control or an agent-output renderer. They render through React only and take `--dsw-*` design tokens from the theme, so they fit any plugin without importing the theme or the slot system. @@ -50,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | -| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. | +| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index f91435e44a..8ddefde928 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -25,6 +25,8 @@ kind: "package-library" ## 使用本包 +本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 + 只要 Web 客户端需要标准控件或 agent 输出渲染器,就用这些原子组件拼装功能 UI。它们只经 React 渲染,并从主题取得 `--dsw-*` 设计 token,因此无需导入主题或 slot 系统即可适配任意插件。 @@ -50,7 +52,7 @@ kind: "package-library" | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | -| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。 | +| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 68614c11fd..2613c3aa98 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -22,32 +22,30 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { - "@shikijs/langs": "^4.3.1", - "@types/mdast": "^4.0.4", - "anser": "^2.3.5", - "clsx": "^2.0.0", - "katex": "^0.16.47", - "mdast-util-from-markdown": "^2.0.3", - "mdast-util-gfm": "^3.1.0", - "mdast-util-math": "^3.0.0", - "micromark-core-commonmark": "^2.0.3", - "micromark-extension-gfm": "^3.0.0", - "micromark-extension-math": "^3.1.0", - "micromark-factory-space": "^2.0.1", - "micromark-util-character": "^2.1.1", - "micromark-util-classify-character": "^2.0.1", - "micromark-util-sanitize-uri": "^2.0.1", - "micromark-util-symbol": "^2.0.1", - "micromark-util-types": "^2.0.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "shiki": "^4.3.1" - }, "devDependencies": { "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0", + "anser": "^2.3.5", + "katex": "^0.16.47", + "micromark-core-commonmark": "^2.0.3", + "micromark-util-character": "^2.1.1", + "micromark-util-classify-character": "^2.0.1", + "micromark-util-symbol": "^2.0.1", + "micromark-util-types": "^2.0.2", + "shiki": "^4.3.1", + "@shikijs/langs": "^4.3.1", + "@types/mdast": "^4.0.4", + "micromark-factory-space": "^2.0.1", + "micromark-extension-math": "^3.1.0", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-gfm": "^3.1.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "micromark-util-sanitize-uri": "^2.0.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index f1beebe5f8..5f726e8474 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -96,3 +96,28 @@ .plain { color: var(--dsw-alias-label-primary); } + +.numbered :where(pre) code { + display: block; + counter-reset: source-line; + white-space: normal; +} + +.numbered :where(pre) code > :global(.line) { + position: relative; + display: block; + min-height: 1lh; + padding-inline-start: calc(var(--dsl-code-block-line-number-width) + 12px); + counter-increment: source-line; + white-space: var(--dsl-code-block-line-white-space, pre-wrap); +} + +.numbered :where(pre) code > :global(.line)::before { + position: absolute; + inset-inline-start: 0; + width: var(--dsl-code-block-line-number-width); + color: var(--dsw-alias-label-tertiary); + text-align: end; + content: counter(source-line); + user-select: none; +} diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 5ba93069dd..fff6a00a45 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -1,5 +1,5 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react' -import type { ReactNode } from 'react' +import type { CSSProperties, ReactNode } from 'react' import clsx from 'clsx' import { writeClipboard } from '../clipboard.ts' import { @@ -25,6 +25,8 @@ export interface CodeBlockProps { streaming?: boolean | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined + /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */ + lineNumbers?: boolean | undefined /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */ copyLabel: string /** Copy-button label during the post-copy confirmation window. */ @@ -57,8 +59,9 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { ) } -export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }: CodeBlockProps) { +export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code + const sourceLines = lineNumbers ? trimmed.split('\n') : undefined const rootRef = useRef(null) const highlighting = useViewportHighlighting(rootRef, lang) // Re-render when a lazy grammar finishes loading, so a fence that showed plain @@ -160,14 +163,20 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL ? streamedBody : html === undefined ? ( -
{trimmed}
+
{sourceLines === undefined ? trimmed : sourceLines.map((line, index) => (
+          {index > 0 && '\n'}{line}
+        ))}
) : (
) return ( -
+
{lang ?? ''}
diff --git a/packages/client/ui-primitives/tests/code-block.client.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx index 2f49687c53..4413cf46d2 100644 --- a/packages/client/ui-primitives/tests/code-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.client.spec.tsx @@ -65,6 +65,49 @@ describe('CodeBlock', () => { expect(pre).not.toBeNull() expect(pre!.textContent).toBe('const a = 1') expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1) + expect(view.container.querySelector('[data-line-numbers]')).toBeNull() + }) + + it.each(['ts', 'unregistered'])('numbers %s source lines without copying the gutter', async (lang) => { + const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockResolvedValue(undefined) + vi.useFakeTimers() + try { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const code = 'const first = 1\n\nconst last = 3' + const view = render() + expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull() + expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent)) + .toEqual(['const first = 1', '', 'const last = 3']) + expect(view.container.querySelector('pre')!.textContent).toBe(code) + await act(async () => { fireEvent.click(view.getByRole('button', { name: '复制' })) }) + expect(writeText).toHaveBeenCalledWith(code) + await act(async () => { await vi.runOnlyPendingTimersAsync() }) + } finally { + cleanup() + vi.useRealTimers() + if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', clipboard) + } + }) + + it('keeps an empty numbered line and widens the gutter as streaming content grows', () => { + const view = render() + expect(view.container.querySelectorAll('code > .line')).toHaveLength(1) + expect(view.container.querySelector('pre')!.textContent).toBe('') + const gutter = () => view.container.querySelector('[data-line-numbers]')! + .style.getPropertyValue('--dsl-code-block-line-number-width') + expect(gutter()).toBe('2ch') + view.rerender() + const code = ['const first = 1', ...Array.from({ length: 99 }, (_, index) => `const line${index} = 0`)].join('\n') + view.rerender() + expect(view.container.querySelectorAll('code > .line')).toHaveLength(100) + const firstLine = view.container.querySelector('code > .line') + expect(view.container.querySelector('pre')!.textContent).toBe(code) + expect(gutter()).toBe('3ch') + view.rerender() + expect(view.container.querySelector('code > .line')).toBe(firstLine) + expect(gutter()).toBe('3ch') }) it('renders the plain arm for an unknown language with the text verbatim', () => { diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index d7b50a43ff..2e57deca88 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 342af5b1fa..2aefdb4b1e 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -40,9 +40,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "use-sync-external-store": "1.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -55,7 +52,8 @@ "@types/react-dom": "~18.3.0", "@types/use-sync-external-store": "^1.5.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "use-sync-external-store": "1.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index c30441e55c..e47d9ad54a 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 865ec5c225..9996019293 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index e86cb10e45..f5d7229b25 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: c5bf1c1ccf0215e5902e65e66aea465a94350b9c -README.zh.md: 8fa4b48640374f414162a4f129d2ef177efb6287 +README.md: 845d9c48dce2264d478f0ac854ef85a480828a14 +README.zh.md: 4a96e78a13dd8e6aefd759926f6e2cb153f0b259 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index c5bf1c1ccf..845d9c48dc 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -25,7 +25,7 @@ Use this package to give the dsh web client a Settings panel, connection-recover ## Use this package -Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension. Automatic recovery shows **Reconnecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, left-aligned text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. +Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension. Automatic recovery shows **Reconnecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, left-aligned text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. ### The General section diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 8fa4b48640..4a96e78a13 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;自动恢复期间显示**自动重连中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。所有可见状态的文字都左对齐,且图标、文字起点、高度和宽度保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 +用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;自动恢复期间显示**自动重连中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。所有可见状态的文字都左对齐,且图标、文字起点、高度和宽度保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 ### 「通用」分区 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index af604ae5ea..2d33e995f4 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -43,8 +43,7 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "clsx": "^2.0.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" @@ -64,7 +63,8 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index d5b1df190a..e0b20dceed 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -1,9 +1,9 @@ /** * Settings shell root: the sidebar-foot trigger row plus the centered modal * panel (figma 501:29947, 1080x700) with the section nav rail. The shell is - * a pure composition face — every piece of text (trigger label, panel title, + * a pure composition face — slot-owned text (trigger label, panel title, * close label, sections) arrives from registrants through slots; accessible - * names resolve to that content (trigger: its own text; dialog: + * names resolve from localized content (trigger: shell locale; dialog: * 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 @@ -184,6 +184,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) { ref={triggerButton} type="button" className={clsx(css.trigger, !wide && css.rail)} + aria-label={t('trigger')} aria-haspopup="dialog" aria-expanded={open} onClick={() => { setOpen(true) }} diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index c3daeaaeb6..c9cb8eadad 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -13,6 +13,7 @@ import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) /** Store over a real mirror derived from the same scripted context. */ function derivedDocumentStore(remote: object) { @@ -32,7 +33,7 @@ const unusedHook = (() => { throw new Error('unused by settings-general componen type AttentionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention) -const kit = { useSessions: unusedHook, useSessionPendingInteraction, useResource, useWorkspaces: unusedHook } +const kit = { useSessions: unusedHook, useSessionPendingInteraction, usePanelInfo, useResource, useWorkspaces: unusedHook } describe('chrome content', () => { it('TriggerContent renders the icon with the label in the wide column', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index 3ebce030b4..a6a953aa34 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -6,10 +6,11 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' -import { en } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(() => { cleanup() @@ -34,6 +35,7 @@ const useSessionPendingInteraction: SettingsRootComponentProps['useSessionPendin function mount({ wide = true, + dictionary = en, connectionState = 'connected', onboardingActive = true, rows = [ @@ -47,6 +49,7 @@ function mount({ ], }: { wide?: boolean + dictionary?: typeof en | typeof zh connectionState?: ConnectionSnapshot onboardingActive?: boolean rows?: Row[] @@ -76,11 +79,11 @@ function mount({ const props: SettingsRootComponentProps = { useSessions, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: unusedHook, wide, reconnect, - t: makeTranslate(en), + t: makeTranslate(dictionary), useConnectionState: (select) => { const [, force] = useState(0) useEffect(() => { @@ -126,20 +129,23 @@ function openPanel() { } describe('SettingsRoot trigger', () => { - it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => { - const { renderSlot } = mount() - const trigger = screen.getByRole('button', { name: 'Settings' }) - expect(trigger.hasAttribute('aria-label')).toBe(false) - expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true }) + it.each([ + { column: 'expanded English', wide: true, dictionary: en, name: 'Settings' }, + { column: 'collapsed English', wide: false, dictionary: en, name: 'Settings' }, + { column: 'expanded Chinese', wide: true, dictionary: zh, name: '设置' }, + { column: 'collapsed Chinese', wide: false, dictionary: zh, name: '设置' }, + ])('uses the locale name and accepts keyboard-style activation for the $column trigger', ({ + wide, dictionary, name, + }) => { + const { renderSlot } = mount({ wide, dictionary }) + const trigger = screen.getByRole('button', { name }) + expect(trigger.getAttribute('aria-label')).toBe(name) + expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide }) expect(trigger.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(trigger) + trigger.focus() + fireEvent.click(trigger, { detail: 0 }) expect(screen.getByRole('dialog')).toBeTruthy() - expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy() - }) - - it('hands the rail state to the trigger seat', () => { - const { renderSlot } = mount({ wide: false }) - expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false }) + expect(screen.getByRole('button', { name, expanded: true })).toBeTruthy() }) it('shows outage, retry progress, and a two-second recovery confirmation', () => { diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index b60f05babf..102eeb21d6 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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-models/README.md -README.md: 64a295e9ebacdd8cbb2b5a0a10b14c1c1dfb24ee -README.zh.md: a75b6f2300eb59e2961b5ca85799b28b32d0856a +README.md: 8c852023f6c7c90a4aad67beef8c4a3081e5968e +README.zh.md: 6b5fd016dbc17bced816a16192c80640f56b9ab2 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 64a295e9eb..8c852023f6 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) Open the Models page from the Settings navigation to see every configured provider as a row. A whole-section provider whose key is not configured anywhere renders as its open setup card instead, but only in the first-run posture and only until the user closes that card. Each card kind owns its own open state, so closing one never discards a draft in another. +A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic. + ### API keys The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index a75b6f2300..6b5fd016db 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 +存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 + ### API 密钥 编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 8783b91bf6..da707bee60 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index b99767a364..54b0610888 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -290,7 +290,8 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS // step: whether the user already has a provider to talk to. const anyUsable = state.rows.some(providerUsable) const configured = state.rows.filter(row => row.configured) - const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '') + const configurable = state.rows.filter(row => state.namespaces.has(row.entry.settingsNs)) + const addable = configurable.filter(row => !row.configured) const addTarget = adding ? editing : undefined const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs) // The draft's directory row, for the card extension seat. A refresh can drop @@ -322,11 +323,15 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS const namespace = state.namespaces.get(target.settingsNs) /* v8 ignore next -- the join marks a row configured only when its namespace resolved */ if (namespace === undefined) return null + const error = row.entry.error === undefined + ? null + :

{row.entry.error}

if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) { // First-run posture: the provider exists but has no key — the // setup card IS its presence on the page, until the user closes it. return (
  • + {error} {renderProviderEditor({ target, namespace, @@ -416,6 +421,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS : null}
  • + {error} {renderSlot( 'settings.models.provider-card', { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) }, @@ -504,37 +510,41 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS // and equal-width so they read as siblings and line up with the // rows above, rather than two pills of different lengths.
    - - + {configurable.length > 0 && ( + + )} + {state.namespaces.has('llm-pi-ai') && ( + + )}
    )}
    diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index fd8eeaf9f9..2af1d54d5c 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -30,6 +30,7 @@ export interface ProviderDirectoryEntry { readonly settingsPath: readonly string[] readonly active: boolean readonly declared?: boolean + readonly error?: string } /** @@ -51,6 +52,7 @@ export function joinProviderDirectory( settingsPath: [...entry.settingsPath], active: active.has(entry.provider), ...entry.declared === undefined ? {} : { declared: entry.declared }, + ...entry.error === undefined ? {} : { error: entry.error }, })) for (const provider of registered) { if (declared.has(provider.id)) continue diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 6d100d800e..0d72c77713 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -307,12 +307,67 @@ async function mountDeepSeekCard(overrides: Parameters[0] = } describe('ModelsSection', () => { + it('hides both add actions when their settings namespaces are absent', async () => { + const scripted = scriptedFace() + scripted.face.settings.describe.mockResolvedValue(remoteOk({ writable: true, hasDocument: false, namespaces: [] })) + await mountFace(scripted) + expect(screen.queryByRole('button', { name: en.add })).toBeNull() + expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull() + }) + + it('offers only providers whose settings namespace can open an editor', async () => { + const scripted = scriptedFace() + scripted.face.settings.describe.mockResolvedValue(remoteOk({ + writable: true, hasDocument: false, + namespaces: wireNamespaces().filter(view => view.ns !== 'llm-pi-ai'), + })) + await mountFace(scripted) + expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.queryByRole('option', { name: 'anthropic' })).toBeNull() + expect(screen.getByRole('option', { name: 'plain' })).toBeTruthy() + }) + + it('shows a catalog diagnostic while keeping the provider editable', async () => { + const scripted = scriptedFace() + const failure = 'llm-pi-ai: provider "openai" model "111" needs an api' + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], error: failure }, + ])) + await mountFace(scripted) + expect(screen.getByRole('alert').textContent).toBe(failure) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + expect(await screen.findByLabelText(en.keyInput)).toBeTruthy() + expect(screen.getByRole('button', { name: en.add })).toBeTruthy() + expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() + }) + it('renders nothing before the slot injects its dependencies', () => { const uninjected = {} as ModelsSectionProps render() expect(document.body.textContent).toBe('') }) + it('shows a configuration diagnostic inside the first-run setup card', async () => { + const scripted = scriptedFace() + const failure = 'The provider configuration needs repair' + scripted.face.llm.listProviders.mockResolvedValue(remoteOk([ + { id: 'deepseek-official', name: 'DeepSeek' }, + ])) + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], error: failure }, + ])) + scripted.face.credentials.describe.mockResolvedValue(remoteOk({ + DEEPSEEK_API_KEY: { configured: false, writable: true }, + })) + await mountFace(scripted) + + const card = screen.getByRole('listitem') + expect(within(card).getByRole('alert').textContent).toBe(failure) + expect(within(card).getByLabelText(en.keyInput)).toBeTruthy() + expect(within(card).queryByRole('button', { name: deepSeekCopy(en.editProvider) })).toBeNull() + }) + it('dispatches the provider-card seat per rendered row, keyed by the owning namespace', async () => { const { renderSlot } = await mountSection() const cards = cardSeatCalls(renderSlot) diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 9f10184eaa..f4a05c9363 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -17,6 +17,7 @@ import { settingsSchema } from './settings-schema.client.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(() => { cleanup() @@ -147,7 +148,7 @@ function harness(options: { openSection, useSessions: unusedHook, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 7757cf3792..0ce5a995a1 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -4,7 +4,17 @@ import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' -import { ModelsSettingsStore } from '../src/client/store.ts' +import { joinProviderDirectory, ModelsSettingsStore } from '../src/client/store.ts' + +it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => { + expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{ + provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], + error: 'catalog unavailable', + }])).toEqual([{ + provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], + active, error: 'catalog unavailable', + }]) +}) let nextRpc = 0 function ok(value: T): RpcResponse { diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 59b2b7acd8..41abead4e8 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -10,6 +10,7 @@ import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) /** Stateless schema service for scope construction in this jsdom fixture. */ const schemaService = new SettingsSchemaService(new Context()) @@ -93,7 +94,7 @@ function mount( openSection: vi.fn(), useSessions: unusedHook, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: unusedHook, controller, useWelcome: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index a322b33c31..84d0b26e40 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index d0eacaa783..c4b27fae88 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, @@ -54,14 +54,12 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ], - "dependencies": { - "clsx": "^2.0.0" - } + ] } diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 92084e335a..305ecef2fa 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.3-alpha.2", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-textpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml similarity index 68% rename from packages/client/ui-sidebar-textpreview/README.i18n.yaml rename to packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 3336e9ce03..29cc626b7e 100644 --- a/packages/client/ui-sidebar-textpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-textpreview/README.md -README.md: aa8364023637598ca84635321376fed07eef90cf -README.zh.md: ab157cb83045f414bf9cc9547c838d5c41cfd286 +# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md +README.md: b4bf0cadce772f3ae596f8e62ab98e33db82cb46 +README.zh.md: f4eea40230ed9d3ed7669b9980491dfe0afab79e diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md new file mode 100644 index 0000000000..b4bf0cadce --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -0,0 +1,90 @@ +--- +description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, PDF and HTML renderers, and plain-text fallback." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-documentpreview + +English | [中文](README.zh.md) + +## Summary + +Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF and HTML receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`. + +## Table of Contents + +- [What it registers](#what-it-registers) +- [Addresses](#addresses) +- [How it reads](#how-it-reads) +- [Navigation](#navigation) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## What it registers + +- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path, and a dropdown for matching renderers plus plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling. +- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. + +Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations. + + +## Addresses + +A tab uses the Session address built by `fileAddressFor`, carrying a relative or absolute path. `hostFileOf(address)` takes the Session only from that address, with no external Session argument; neither current nor Tab Session is borrowed. The Host resolves file and related paths through the Session filesystem, whose backend controls read authority. Metadata for the same complete address is shared by every UI, including Global components. The [Workspace Files README](../../api/workspace-files/README.md) owns these rules; renderer selection does not change the navigation address. + + +## How it reads + +The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads: + +- The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content. +- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. Failures retain loaded content and offer a retry. +- **Complete bytes** — PDF and HTML use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. +- **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice. + +HTML runs in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. + +Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. + +Initial reads, additional pages, and HTML/PDF preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. + + +## Navigation + +`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers expose source-line anchors; Markdown does not. A navigation remains pending while its selected renderer has no anchor and runs if the user switches to plain text or code. Code navigation places the target below its sticky toolbar. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision. + + +## Model Experience + +None, as the preview is a browser-only viewer that registers no tool, prompt section, or session event. + +#### KV Cache effect + +No direct effect; what the user reads here never enters a model request. + +## Known Limitations and Deferred Work + + +- **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. +- **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF and HTML require a complete result within the Host's `maxFileBytes` cap. +- **Byte-view scroll state is not restored.** PDF and HTML can return to the top when their renderer remounts or reloads; HTML iframe scrolling belongs to its opaque browsing context. +- **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. +- **Package-local wrap glyph.** `IconWrapOutline16` lives in `src/client/icons.tsx` until the shared icon set carries one; the props contract already matches. +- **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. + + +### Dev Note + +
    +Working context for maintainers — click to expand + +None. + +
    + +**Runtime invariant:** No companion is published. Renderer metadata, document loading, and view state belong to the local registry and declared Slot stores, with no independent runtime source to compare against; registration disposal and tab lifetimes are covered by behavior tests. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md new file mode 100644 index 0000000000..f4eea40230 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -0,0 +1,90 @@ +--- +description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、PDF 和 HTML 渲染器,并以纯文本兜底。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-documentpreview + +[English](README.md) | 中文 + +## 概述 + +在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF 和 HTML 接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。 + +## 目录 + +- [注册了什么](#what-it-registers) +- [地址](#addresses) +- [怎么读](#how-it-reads) +- [导航](#navigation) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 注册了什么 + +- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径,并提供匹配渲染器及纯文本的下拉选择。仅当所选渲染器声明 `wrap: true` 时显示换行开关;该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动。 +- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 + +文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。 + + +## 地址 + +tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路径。`hostFileOf(address)` 仅从地址取得 Session,不接收外部 Session 参数,也不借用当前或 Tab Session。Host 通过 Session 文件系统解析文件及关联路径,由该后端控制读取权限。任何 UI(包括 Global 组件)都共享同一完整地址的元数据。[Workspace Files README](../../api/workspace-files/README.zh.md)定义这些规则;渲染器选择不改变导航地址。 + + +## 怎么读 + +正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取: + +- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。 +- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。失败时保留已加载内容并提供重试。 +- **完整字节** —— PDF 和 HTML 通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 +- **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。 + +HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 + +共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。 + +首次读取、追加页及 HTML/PDF 准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。 + + +## 导航 + +`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航会把目标放在置顶工具栏下方。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。 + + +## 模型体验 + +无,因为预览是纯浏览器侧的查看器,不注册工具、提示词段或会话事件。 + +#### KV Cache 影响 + +无直接影响;用户在这里读到的东西永不进入模型请求。 + +## 已知限制与延期工作 + + +- **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 +- **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF 和 HTML 必须取得 Host `maxFileBytes` 上限内的完整结果。 +- **字节视图不恢复滚动位置。** PDF 与 HTML 的渲染器重新挂载或重新载入时可能回到顶部;HTML iframe 的滚动属于其不透明浏览上下文。 +- **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 +- **换行图标为包内自绘。** `IconWrapOutline16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;props 契约已经一致。 +- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 + + +### 开发备注 + +
    +维护者工作上下文——点击展开 + +无。 + +
    + +**运行时不变式:** 不发布伴生入口。渲染器元数据、文档加载和视图状态归本地注册表与声明的 Slot store 所有,没有可比对的独立运行时来源;注册释放和 tab 生命周期由行为测试覆盖。 diff --git a/packages/client/ui-sidebar-textpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json similarity index 82% rename from packages/client/ui-sidebar-textpreview/package.json rename to packages/client/ui-sidebar-documentpreview/package.json index 5df323e5a0..a008ef19ee 100644 --- a/packages/client/ui-sidebar-textpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -1,14 +1,14 @@ { - "name": "@deepseek-ai/dsh-client-ui-sidebar-textpreview", - "description": "Text preview tab type for the right Sidebar: the fallback viewer for file: addresses, drawn from the file resource with line navigation, wrap, and reload", - "version": "0.1.3-alpha.2", + "name": "@deepseek-ai/dsh-client-ui-sidebar-documentpreview", + "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, PDF, HTML, and plain text", + "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" }, "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/ui-sidebar-textpreview" + "directory": "packages/client/ui-sidebar-documentpreview" }, "type": "module", "main": "lib/index.js", @@ -28,6 +28,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-gateway", "@deepseek-ai/dsh-api-workspace-files", "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-session", @@ -41,16 +42,12 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -67,7 +64,11 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0" + "@types/react-dom": "~18.3.0", + "clsx": "^2.0.0", + "pdfjs-dist": "6.3.289", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.module.css b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.module.css new file mode 100644 index 0000000000..017ad8a7af --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.module.css @@ -0,0 +1,25 @@ +.loading { + display: inline-flex; + gap: 8px; + align-items: center; + vertical-align: middle; + white-space: normal; +} + +.icon { + display: flex; + flex: none; + animation: turn 0.8s linear infinite; +} + +@keyframes turn { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .icon { + animation: none; + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx new file mode 100644 index 0000000000..fff0ba2e3a --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx @@ -0,0 +1,13 @@ +/** Shared indeterminate loading feedback for document reads and rendering. */ +import type { ReactNode } from 'react' +import clsx from 'clsx' +import { IconLoadingOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './LoadingIndicator.module.css' + +/** @param props - localized status label and optional placement style. @returns an animated, accessible loading status. */ +export function LoadingIndicator({ label, className }: { label: string; className?: string | undefined }): ReactNode { + return + + {label} + +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css similarity index 91% rename from packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css rename to packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index 1bd16a6a23..49397c41be 100644 --- a/packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -66,10 +66,15 @@ word-break: break-word; } +/* Sticky code banners meet the scrollport edge; prose owns its initial spacing. */ +.body:has([data-code-preview], [data-document-markdown]) { + padding: 0; +} + /* One page of lines; pages abut so the file reads as one. */ .page { margin: 0; - font: inherit; + font: var(--dsw-font-markdown-code-block); white-space: inherit; } @@ -167,3 +172,13 @@ color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } + +.viewerTool { + width: auto; + max-width: 160px; + padding: 0 6px; + overflow: hidden; + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx new file mode 100644 index 0000000000..8e47f7decc --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -0,0 +1,279 @@ +/** + * The text preview's body: a file's content, or the reason it is not showing. + * + * Two sources meet here. The standard `useResource` hook gives the file's + * metadata — its version — and this type's + * own store holds the content it read through its face. A Host-reported change is + * announced, not applied: reloading under a reader would lose their place, so + * the bar waits for a click. A failed metadata frame — the file gone, its + * workspace unknown — takes the same bar's place over the pages already loaded, + * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of + * the path row; the Sidebar's strip carries none of them. + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { IconRefreshOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TextInjected } from './face.ts' +import { failureLine } from './failure-line.ts' +import { IconWrapOutline16 } from './icons.tsx' +import { LoadingIndicator } from './LoadingIndicator.tsx' +import { hostFileOf } from './rpc.ts' +import type { TextStore } from './store.ts' +import type { DocumentContent } from './document/contract.ts' +import { matchingDocumentPreviews } from './document/registry.ts' +import type { DocumentPreviewDefinition } from './document/registry.ts' +import { PLAIN_BODY_ID } from './text/index.ts' +import { loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts' +import css from './TextPreview.module.css' + +export { linesOf, loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts' +export type { LoadedPage } from './text/lines.ts' + +/** Private registration inputs; the framework binds the registry source to useDocumentPreviews. */ +export interface TextPreviewInjected extends TextInjected { + readonly hooks: { readonly documentPreviews: ObservableSnapshot } +} + +/** The body's composed props: the tab, its navigation, the shared store and face, and copy. */ +export type TextPreviewProps = + & PropsRuntime<'sidebar.right.pane.tab'> + & PropsRenderSlots<'sidebar.right.tab.document'> + & PropsStore + & InjectFace + & PropsLocale<'sidebarDocumentPreview'> + +/** + * The text type's body, registered under `sidebar.right.pane.tab` as `text`. + * @param props - composed slot props. + * @returns the content read so far with its controls, or a progress line. + */ +export function TextPreview({ + useTabInfo, useResource, useStore, actions, loadPage, reloadPages, + loadAll, reloadAll, useDocumentPreviews, renderSlot, t, +}: TextPreviewProps): ReactNode { + const { tab } = useTabInfo() + const { navigation, signal } = tab + const meta = useResource<'file'>(tab.contentId) + const canRead = meta.status !== 'none' + const file = useMemo(() => hostFileOf(tab.contentId), [tab.contentId]) + const state = useStore(s => s.byTab[tab.id]) + const definitions = useDocumentPreviews(value => value) + const candidates = useMemo(() => { + const matched = matchingDocumentPreviews(definitions, file.path) + const fallback = definitions.find(definition => definition.id === PLAIN_BODY_ID) + return fallback === undefined ? matched : [...matched, fallback] + }, [definitions, file.path]) + const selected = candidates.find(candidate => candidate.id === state?.rendererId) ?? candidates[0] + const mode = selected?.loading + const current = (state?.mode ?? 'text-pages') === mode ? state : undefined + const bodyRef = useRef(null) + const [menuOpen, setMenuOpen] = useState(false) + // Every tab of this type is a `file` resource address, so its params are the + // `file` type's; the union is narrowed on the one field read, not validated. + const line = navigation.params !== undefined && 'line' in navigation.params ? navigation.params.line : undefined + const pages = current?.pages + const loaded = useMemo(() => loadedPages(pages ?? {}), [pages]) + const loadedThrough = lastLineLoaded(loaded) + const hasContent = loaded.length > 0 || current?.complete !== undefined + + // First mount reads the first page; a body coming back to a tab with content + // reads nothing, because the store outlives the body. + const started = current !== undefined + useEffect(() => { + if (started || !canRead || mode === undefined) return + if (mode === 'text-pages') loadPage(tab.id, file, 1, signal, meta.value?.version) + else loadAll(tab.id, file, signal, meta.value?.version) + }, [started, tab.id, file, signal, loadPage, loadAll, canRead, mode, meta.value?.version]) + + // Come back where the reader was once there is content to scroll: on a remount, + // and after a reload rebuilt the content. Keyed on content presence only, so a + // scroll write never re-lands. + useEffect(() => { + const body = bodyRef.current + if (hasContent && body !== null && state !== undefined) body.scrollTop = state.scrollTop + }, [hasContent]) + + // Answer a navigation once: a line the pages do not reach yet loads the next + // page (again, until the pages cover it or the file ends); a line they hold + // is scrolled to and marked. The store remembers the answer, so a remount + // restores the reader's place instead. + useEffect(() => { + const body = bodyRef.current + if (current === undefined || body === null || current.revision === navigation.revision) return + if (line === undefined || mode !== 'text-pages') { + actions.navigated(tab.id, navigation.revision) + return + } + if (line > loadedThrough && !current.eof) { + if (!current.loading && current.failure === undefined && canRead) { + loadPage(tab.id, file, loadedThrough + 1, signal, meta.value?.version) + } + return + } + const landed = scrollToLine(body, line) + if (!landed && line <= loadedThrough) return + actions.navigated(tab.id, navigation.revision) + // Recorded here as well as by the scroll event, so the store holds the + // landing before any later navigation reads it. + actions.scrolled(tab.id, body.scrollTop) + }, [ + navigation.revision, line, loadedThrough, current?.eof, current?.loading, current?.failure, started, + selected?.id, mode, file, canRead, meta.value?.version, + ]) + + const content = useMemo((): DocumentContent | undefined => { + if (mode === 'bytes-complete') { + return current?.complete === undefined ? undefined : { kind: 'bytes', data: current.complete.data } + } + if (current === undefined || loaded.length === 0) return undefined + return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current.eof } + }, [mode, loaded, current?.complete, current?.eof]) + + if (state === undefined || selected === undefined) { + return ( +
    + {meta.status === 'none' + ?

    {t('resourceUnavailable')}

    + : } +
    + ) + } + const next = loadedThrough + 1 + const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path + const observedVersion = meta.value?.version + const changed = current?.version !== undefined && observedVersion !== undefined + && observedVersion !== current.version && observedVersion !== current.observedVersion + const loadNext = (): void => { + if (!canRead || current?.loading || current?.eof) return + if (mode === 'text-pages') loadPage(tab.id, file, next, signal, meta.value?.version) + else loadAll(tab.id, file, signal, meta.value?.version) + } + const reload = (): void => { + if (!canRead) return + if (mode === 'text-pages') reloadPages(tab.id, file, signal, meta.value?.version) + else reloadAll(tab.id, file, signal, meta.value?.version) + } + return ( +
    + {meta.failure !== undefined + ? ( + // The file's metadata failed — gone, or its workspace unknown — which + // outranks a pending change; the pages already read stay under it. +

    + {failureLine(t, meta.failure)} + +

    + ) + : changed && ( +

    + {t('changed')} + +

    + )} +
    +
    {displayPath}
    + { setMenuOpen(value => !value) }}> + {selected.title()} + + )} + items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))} + selectedId={selected.id} + onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }} + onClose={() => { setMenuOpen(false) }} + align="end" + portal + dense + /> + {selected.wrap === true && ( + + )} + +
    +
    { + const body = event.currentTarget + actions.scrolled(tab.id, body.scrollTop) + if (mode === 'text-pages' && current?.failure === undefined && body.clientHeight > 0 + && body.scrollTop + body.clientHeight >= body.scrollHeight - 1) loadNext() + }} + > + {!hasContent && current?.failure === undefined && ( + + )} + {content !== undefined && renderSlot('sidebar.right.tab.document', { + resourceAddress: tab.contentId, content, wrap: state.wrap, + }, { + entryKey: selected.id, hookContext: useTabInfo, + fallback:

    {t('rendererUnavailable', { name: selected.title() })}

    , + })} + {current?.failure !== undefined && ( +

    + {failureLine(t, current.failure)} + +

    + )} + {mode === 'text-pages' && current !== undefined && loaded.length > 0 && !current.eof && current.failure === undefined && ( + + )} +
    +
    + ) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css new file mode 100644 index 0000000000..27e1fa813b --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css @@ -0,0 +1,34 @@ +.renderer { + min-height: 100%; + min-width: 0; + white-space: normal; + background: var(--dsw-alias-markdown-code-block); +} + +.renderer .code { + --dsl-code-block-border-radius: 0px; + --dsl-code-block-line-white-space: pre; + /* Source lines resolve their offset against the document owner's scroller. */ + position: static; + margin: 0; + min-width: 0; +} + +/* Both axes scroll in the document owner, not in an inner code window. */ +.renderer .code pre { + box-sizing: border-box; + min-width: 100%; + overflow: visible; + white-space: pre; + word-break: normal; + overflow-wrap: normal; +} + +.renderer[data-wrap='true'] .code { + --dsl-code-block-line-white-space: pre-wrap; +} + +.renderer[data-wrap='true'] .code pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx new file mode 100644 index 0000000000..605309594c --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx @@ -0,0 +1,33 @@ +/** Incrementally highlighted source; the document owner supplies the accumulated text and wrap preference. */ +import type { ReactNode } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import type { DocumentPreviewProps } from '../document/contract.ts' +import { languageForPath } from './languages.ts' +import type {} from './locales.ts' +import css from './CodeBody.module.css' + +/** Document owner props and this renderer's localized controls. */ +export type CodeBodyProps = DocumentPreviewProps & PropsLocale<'sidebarCodePreview'> + +/** @param props - accumulated document contents and framework props. @returns one stable CodeBlock, or no body for byte contents. */ +export function CodeBody({ resourceAddress, content, wrap, t }: CodeBodyProps): ReactNode { + if (content.kind !== 'text') return null + const file = parseFileAddress(resourceAddress) + if (file === undefined) throw new Error(`ui-sidebar-documentpreview: not a file address "${resourceAddress}"`) + const language = languageForPath(file.path) + return ( +
    + +
    + ) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/code/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/code/index.ts new file mode 100644 index 0000000000..dcbbd67ecb --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/code/index.ts @@ -0,0 +1,26 @@ +/** Code preview metadata and body registered through the public document extension points. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '../index.ts' +import { CodeBody } from './CodeBody.tsx' +import { CODE_EXTENSIONS } from './languages.ts' +import { en, zh } from './locales.ts' + +const ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code' +const NS = 'sidebarCodePreview' + +/** @param ctx - owning plugin context. Register localized metadata and the matching keyed document body. */ +export function apply(ctx: Context): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en })) + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.documentPreviews.register({ + id: ID, + extensions: CODE_EXTENSIONS, + priority: 'builtin', + title: () => t('title'), + loading: 'text-pages', + wrap: true, + })) + ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register( + { name: 'sidebar.right.tab.document', key: ID, locale: NS }, CodeBody, + ))) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/code/languages.ts b/packages/client/ui-sidebar-documentpreview/src/client/code/languages.ts new file mode 100644 index 0000000000..3423df465b --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/code/languages.ts @@ -0,0 +1,46 @@ +/** File suffixes mapped to grammars already supported by the shared CodeBlock. */ +const languageExtensions: Readonly> = { + typescript: ['ts', 'tsx', 'mts', 'cts'], + javascript: ['js', 'jsx', 'mjs', 'cjs'], + shellscript: ['sh', 'bash', 'zsh'], + json: ['json', 'jsonc', 'jsonl', 'ndjson'], + python: ['py', 'pyw', 'pyi'], + ruby: ['rb', 'rake', 'gemspec'], + go: ['go'], + rust: ['rs'], + java: ['java'], + c: ['c', 'h'], + cpp: ['cc', 'cpp', 'cxx', 'hh', 'hpp', 'hxx'], + csharp: ['cs'], + kotlin: ['kt', 'kts'], + swift: ['swift'], + php: ['php'], + yaml: ['yaml', 'yml'], + toml: ['toml'], + ini: ['ini'], + markdown: ['md', 'markdown'], + mdx: ['mdx'], + html: ['html', 'htm', 'xhtml'], + css: ['css'], + scss: ['scss'], + less: ['less'], + sql: ['sql'], + xml: ['xml', 'xsd', 'xsl', 'xslt'], + lua: ['lua'], +} + +const languages = new Map(Object.entries(languageExtensions) + .flatMap(([language, extensions]) => extensions.map(extension => [extension, language] as const))) + +/** Recognized suffixes shared by renderer selection and language hints. */ +export const CODE_EXTENSIONS: readonly string[] = [...languages.keys()] + +/** + * Select the shared highlighter's grammar for a filename. + * @param path - decoded source filename or path. + * @returns a supported grammar hint, or undefined for other suffixes. + */ +export function languageForPath(path: string): string | undefined { + const extension = /\.([^./]+)$/u.exec(path.replaceAll('\\', '/'))?.[1]?.toLowerCase() + return extension === undefined ? undefined : languages.get(extension) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/code/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/code/locales.ts new file mode 100644 index 0000000000..e251d7f97c --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/code/locales.ts @@ -0,0 +1,23 @@ +/** Locale-owned code renderer name and CodeBlock controls. */ +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Code document implementation name and copy controls. */ + sidebarCodePreview: keyof typeof zh + } +} + +/** Simplified Chinese dictionary and key source. */ +export const zh = { + title: '代码', + copy: '复制', + copied: '已复制', +} + +/** English dictionary with the same keys. */ +export const en = { + title: 'Code', + copy: 'Copy', + copied: 'Copied', +} satisfies Record diff --git a/packages/client/ui-sidebar-textpreview/src/client/definition.ts b/packages/client/ui-sidebar-documentpreview/src/client/definition.ts similarity index 85% rename from packages/client/ui-sidebar-textpreview/src/client/definition.ts rename to packages/client/ui-sidebar-documentpreview/src/client/definition.ts index 9a6cf946b8..7f2892cffd 100644 --- a/packages/client/ui-sidebar-textpreview/src/client/definition.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/definition.ts @@ -1,11 +1,11 @@ /** * Stage one of this package's registration: what the `text` tab type IS. * - * The type claims every `dsh-resource://file/` address in either scope — - * `session//` or `absolute/` — at the `fallback` band: it + * The type claims every `dsh-resource://file/session//` + * address at the `fallback` band: it * is the plain viewer that any more specific type for the same address should * beat, the position VS Code's text editor holds among its editors. `canOpen` - * refuses an address `parseFileAddress` rejects at claim time, where an + * refuses an address `parseFileAddress` rejects or that has no Session at claim time, where an * unclaimed address is the documented wiring error. */ import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' @@ -15,7 +15,7 @@ import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' export const TEXTPREVIEW_KIND = 'text' /** This implementation's identity in the tab system: the key its body registers under. */ -export const TEXTPREVIEW_ID = '@deepseek-ai/dsh-client-ui-sidebar-textpreview' +export const TEXTPREVIEW_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' /** * The tab title for one `file:` address: its decoded basename. @@ -48,7 +48,7 @@ export function textDefinition(): SidebarRightTabDefinition { kind: TEXTPREVIEW_KIND, patterns: ['dsh-resource://file/**'], priority: 'fallback', - canOpen: address => parseFileAddress(address) !== undefined, + canOpen: address => parseFileAddress(address)?.scope === 'session', title: basenameOf, } } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts new file mode 100644 index 0000000000..0ee7fae35d --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts @@ -0,0 +1,54 @@ +/** Document renderer slot: the owner supplies shared file state, renderers own their presentation. */ +import type { PropsRuntime, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots' +import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' + +/** One loaded text window, retaining source line positions. */ +export interface DocumentTextPage { + readonly offset: number + readonly text: string + readonly lines: number +} + +/** + * Contents prepared by the preview owner using ordinary file reads. + * Byte arrays are transient UI input, never persisted layout or Session data. + */ +export type DocumentContent = + | { readonly kind: 'text'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean } + | { readonly kind: 'bytes'; readonly data: Uint8Array } + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Document body selected by a registered implementation id. */ + 'sidebar.right.tab.document': { + kind: 'keyed' + scope: 'session' + owner: { + /** Original file address, also readable through the standard useResource hook. */ + readonly resourceAddress: string + /** Loaded content; text is an accumulated prefix until eof. */ + readonly content: DocumentContent + /** The document toolbar's current wrapping preference. */ + readonly wrap: boolean + } + hookContext: UseSidebarRightTabInfo + inject: { + hooks: { + tabInfo: SlotHookFactory<'sidebar.right.tab.document', UseSidebarRightTabInfo> + } + } + } + } +} + +/** Standard input for every document body; entry-local stores and locale props can be intersected with it. */ +export type DocumentPreviewProps = PropsRuntime<'sidebar.right.tab.document'> + +/** + * Forward the framework's tab reader to the selected document body. + * @param _standard - framework standard props. + * @param useTabInfo - enclosing tab's bound reader. + * @returns the same reader, without another subscription adapter. + */ +export const documentTabInfoFactory: SlotHookFactory<'sidebar.right.tab.document', UseSidebarRightTabInfo> = + (_standard, useTabInfo) => useTabInfo diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts new file mode 100644 index 0000000000..29c583d17e --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts @@ -0,0 +1,103 @@ +/** File-extension preview registrations; component dispatch belongs to the keyed document slot. */ +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' + +/** How the document owner delivers file contents to a renderer. */ +export type DocumentLoadMode = 'text-pages' | 'bytes-complete' + +/** One renderer implementation, independent of its component registration. */ +export interface DocumentPreviewDefinition { + /** Unique implementation name, also used as the document slot key. */ + readonly id: string + /** File suffixes without a leading dot; compound suffixes such as tar.gz are accepted. */ + readonly extensions: readonly string[] + /** External implementations win over product implementations; defaults to extension. */ + readonly priority?: 'builtin' | 'extension' + /** Localized implementation label, evaluated when the toolbar renders. @returns the visible name. */ + readonly title: () => string + /** Content delivery mode supplied by the document owner. */ + readonly loading: DocumentLoadMode + /** Whether the implementation consumes the document's wrap preference. */ + readonly wrap?: boolean +} + +/** + * Rank an observed definition snapshot without consulting mutable service state. + * @param definitions - registered implementations in registration order. + * @param path - decoded filename or file path. + * @returns matching implementations, external band first, then longest suffix. + */ +export function matchingDocumentPreviews( + definitions: readonly DocumentPreviewDefinition[], + path: string, +): readonly DocumentPreviewDefinition[] { + const normalized = path.replaceAll('\\', '/').toLowerCase() + const name = normalized.slice(normalized.lastIndexOf('/') + 1) + return definitions.map((definition, order) => ({ + definition, order, + rank: definition.priority === 'builtin' ? 0 : 1, + length: Math.max(0, ...definition.extensions + .map(extension => extension.toLowerCase().replace(/^\./u, '')) + .filter(extension => name.endsWith(`.${extension}`)) + .map(extension => extension.length)), + })) + .filter(candidate => candidate.length > 0) + .sort((left, right) => right.rank - left.rank || right.length - left.length || left.order - right.order) + .map(candidate => candidate.definition) +} + +/** Observable registry of all live implementations, including lower-priority alternatives. */ +export class DocumentPreviewRegistry { + private readonly registered = new Map() + private readonly listeners = new Set<() => void>() + private snapshot: readonly DocumentPreviewDefinition[] = [] + + /** + * Read the current registrations. + * @returns the same snapshot until a registration changes. + */ + readonly getSnapshot = (): readonly DocumentPreviewDefinition[] => this.snapshot + + /** + * Observe registration changes. + * @param listener - registration-change observer. + * @returns its disposer. + */ + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Register metadata separately from the matching keyed slot component. + * @param definition - unique implementation and recognized suffixes. + * @returns an idempotent disposer; duplicate live implementation names throw. + */ + register(definition: DocumentPreviewDefinition): () => void { + if (this.registered.has(definition.id)) { + throw new Error(`documentPreviews: duplicate implementation "${definition.id}"`) + } + this.registered.set(definition.id, definition) + this.publish() + let active = true + return () => { + if (!active) return + active = false + this.registered.delete(definition.id) + this.publish() + } + } + + /** + * List every matching implementation in automatic-selection order. + * @param path - decoded file path; matching never resolves filesystem access. + * @returns extension band first, then longest suffix, then registration order. + */ + candidates(path: string): readonly DocumentPreviewDefinition[] { + return matchingDocumentPreviews(this.snapshot, path) + } + + private publish(): void { + this.snapshot = [...this.registered.values()] + notifySubscribers(this.listeners, '[document-previews] registry') + } +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/face.ts b/packages/client/ui-sidebar-documentpreview/src/client/face.ts similarity index 56% rename from packages/client/ui-sidebar-textpreview/src/client/face.ts rename to packages/client/ui-sidebar-documentpreview/src/client/face.ts index fdf5f1d282..10ad3049ce 100644 --- a/packages/client/ui-sidebar-textpreview/src/client/face.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/face.ts @@ -19,8 +19,10 @@ import type { BoundActions } from '@deepseek-ai/dsh-client-store' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ReadWorkspaceFilePage, SessionFile } from './rpc.ts' +import type { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from './rpc.ts' +import { documentFileBytes } from './rpc.ts' import type { TextStore } from './store.ts' +import type { DocumentLoadMode } from './document/registry.ts' /** The preview's injected business face, as the body receives it. */ export interface TextInjected { @@ -33,8 +35,9 @@ export interface TextInjected { * @param file - the session and workspace path the tab's address names. * @param offset - 1-based line the page starts at. * @param signal - the tab record's lifetime. + * @param observedVersion - metadata version observed at read start. */ - readonly loadPage: (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal) => void + readonly loadPage: (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal, observedVersion?: string) => void /** * Drop every page and read the first one again, for a file the Host reports * changed. The view is kept, so the reader stays where they were; a page read @@ -42,8 +45,25 @@ export interface TextInjected { * @param tabId - the tab being drawn. * @param file - the session and workspace path the tab's address names. * @param signal - the tab record's lifetime. + * @param observedVersion - metadata version observed at read start. */ - readonly reloadPages: (tabId: TabId, file: SessionFile, signal: AbortSignal) => void + readonly reloadPages: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void + /** + * Read the complete file for a whole-file renderer. + * @param tabId - owning tab. + * @param file - the session and workspace path the tab's address names. + * @param signal - tab lifetime. + * @param observedVersion - metadata version observed at read start. + */ + readonly loadAll: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void + /** + * Discard the old complete result and read again. + * @param tabId - owning tab. + * @param file - the session and workspace path the tab's address names. + * @param signal - tab lifetime. + * @param observedVersion - metadata version observed at read start. + */ + readonly reloadAll: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void } /** @@ -54,14 +74,19 @@ export interface TextInjected { interface TabReads { generation: number version: string | undefined + mode: DocumentLoadMode } /** - * Bind the preview's face to one paged read. + * Bind the preview's face to one paged read and one complete-byte read. * @param read - the bound `workspaceFiles.read` call. + * @param readAll - ordinary complete-byte Remote read. * @returns the Slot `inject` factory: bound actions in, face out. The slot's session id is unused because the address carries its own. */ -export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, actions: BoundActions) => TextInjected { +export function textFace( + read: ReadWorkspaceFilePage, + readAll: ReadDocumentBytes, +): (sessionId: SessionId, actions: BoundActions) => TextInjected { return (_sessionId: SessionId, actions: BoundActions): TextInjected => { const tabs = new Map() // Reached with a live signal only: the record's end forgets the tab's @@ -69,7 +94,7 @@ export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, ac const readsOf = (tabId: TabId, signal: AbortSignal): TabReads => { const held = tabs.get(tabId) if (held !== undefined) return held - const created: TabReads = { generation: 0, version: undefined } + const created: TabReads = { generation: 0, version: undefined, mode: 'text-pages' } tabs.set(tabId, created) signal.addEventListener('abort', () => { tabs.delete(tabId) @@ -77,11 +102,21 @@ export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, ac }, { once: true }) return created } - const loadPage = (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal): void => { - if (signal.aborted) return + const modeOf = (tabId: TabId, signal: AbortSignal, mode: DocumentLoadMode): TabReads => { const reads = readsOf(tabId, signal) + if (reads.mode !== mode) { + reads.mode = mode + reads.generation++ + reads.version = undefined + actions.reset(tabId) + } + return reads + } + const loadPage = (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal, observedVersion?: string): void => { + if (signal.aborted) return + const reads = modeOf(tabId, signal, 'text-pages') const { generation } = reads - actions.loading(tabId) + actions.loading(tabId, 'text-pages', observedVersion) void read(file.sessionId, file.path, offset, signal).then((result) => { if (signal.aborted || reads.generation !== generation) return if (!result.ok) { @@ -91,21 +126,52 @@ export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, ac // Pages of two versions never meet: a newer file past the first line // restarts the walk from line 1, where the store adopts the new version. if (offset !== 1 && reads.version !== undefined && result.value.version !== reads.version) { - restart(tabId, file, signal) + restart(tabId, file, signal, observedVersion) return } reads.version = result.value.version actions.page(tabId, result.value) }) } - const restart = (tabId: TabId, file: SessionFile, signal: AbortSignal): void => { + const loadAll = (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string): void => { + if (signal.aborted) return + const reads = modeOf(tabId, signal, 'bytes-complete') + const { generation } = reads + actions.loading(tabId, 'bytes-complete', observedVersion) + void readAll(file, signal).then((result) => { + if (signal.aborted || reads.generation !== generation) return + if (!result.ok) { + actions.failed(tabId, result.error) + return + } + let file + try { + file = documentFileBytes(result.value) + } catch (error) { + actions.failed(tabId, Object.assign( + new Error('document file byte response has malformed base64 data', { cause: error }), + { name: 'RemoteError', isDSHRemoteError: true as const, code: 'gateway/internal' as const, details: {} }, + )) + return + } + reads.version = file.version + actions.complete(tabId, file) + }) + } + const restart = ( + tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, mode: DocumentLoadMode = 'text-pages', + ): void => { if (signal.aborted) return const reads = readsOf(tabId, signal) reads.generation += 1 reads.version = undefined actions.reset(tabId) - loadPage(tabId, file, 1, signal) + if (mode === 'text-pages') loadPage(tabId, file, 1, signal, observedVersion) + else loadAll(tabId, file, signal, observedVersion) + } + return { + loadPage, reloadPages: restart, loadAll, + reloadAll: (tabId, file, signal, observedVersion) => { restart(tabId, file, signal, observedVersion, 'bytes-complete') }, } - return { loadPage, reloadPages: restart } } } diff --git a/packages/client/ui-sidebar-textpreview/src/client/failure-line.ts b/packages/client/ui-sidebar-documentpreview/src/client/failure-line.ts similarity index 88% rename from packages/client/ui-sidebar-textpreview/src/client/failure-line.ts rename to packages/client/ui-sidebar-documentpreview/src/client/failure-line.ts index a8caee1e1c..edf82ae9c6 100644 --- a/packages/client/ui-sidebar-textpreview/src/client/failure-line.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/failure-line.ts @@ -21,10 +21,9 @@ function humanBytes(bytes: number): string { * @param failure - the settled Remote failure. * @returns the line to show in place of the file. */ -export function failureLine(t: TranslateNS<'sidebarTextpreview'>, failure: RemoteFailure): string { +export function failureLine(t: TranslateNS<'sidebarDocumentPreview'>, failure: RemoteFailure): string { switch (failure.code) { case 'workspace-file/not-found': return t('error.notFound') - case 'workspace-file/outside-workspace': return t('error.outsideWorkspace') case 'workspace-file/too-large': return t('error.tooLarge', { limit: humanBytes(failure.details.limit) }) case 'workspace-file/not-text': return t('error.notText') diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css new file mode 100644 index 0000000000..02f352bb66 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css @@ -0,0 +1,15 @@ +.frame { + display: block; + width: 100%; + height: 100%; + min-height: 240px; + border: none; + background: var(--dsw-alias-bg-base); +} + +.status { + margin: 0; + padding: 10px; + color: var(--dsw-alias-label-secondary); + white-space: normal; +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx new file mode 100644 index 0000000000..34c742786d --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx @@ -0,0 +1,71 @@ +/** Complete HTML rendered in a script-enabled opaque iframe, without parent application access. */ +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { DocumentPreviewProps } from '../document/contract.ts' +import { LoadingIndicator } from '../LoadingIndicator.tsx' +import { createHtmlDocument } from './bootstrap.ts' +import { packHtml } from './pack.ts' +import type { ReadHtmlRelative } from './pack.ts' +import { createReadHtmlRelative } from './read-relative.ts' +import type { ReadHtmlRelated } from './read-relative.ts' +import type {} from './locales.ts' +import css from './HtmlBody.module.css' + +/** Standard document inputs plus this renderer's dictionary. */ +export type HtmlBodyProps = DocumentPreviewProps & PropsLocale<'documentHtml'> & { + /** Ordinary Remote callback bound by this renderer's Slot inject. */ + readonly readRelated: ReadHtmlRelated +} + +type FrameInput = { + readonly data: Uint8Array + readonly readRelative: ReadHtmlRelative +} + +type FrameState = FrameInput & { readonly url: string | undefined } + +/** One mounted file owns its root Blob; replacing content also replaces the browsing context. */ +function HtmlFrame({ data, readRelative, t }: FrameInput & { t: HtmlBodyProps['t'] }): ReactNode { + const [frame, setFrame] = useState() + useEffect(() => { + const controller = new AbortController() + let url: string | undefined + void (async () => { + try { + const bundle = await packHtml(data, readRelative, controller.signal) + controller.signal.throwIfAborted() + const html = createHtmlDocument(bundle) + url = URL.createObjectURL(new Blob([html], { type: 'text/html' })) + setFrame({ data, readRelative, url }) + } catch { + if (!controller.signal.aborted) setFrame({ data, readRelative, url: undefined }) + } + })() + return () => { + controller.abort() + if (url !== undefined) URL.revokeObjectURL(url) + } + }, [data, readRelative]) + + if (frame?.data !== data || frame.readRelative !== readRelative) { + return + } + if (frame.url === undefined) return

    {t('failed')}

    + return