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-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-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-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..9ccf82b4a5 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: eb5eb14f28a6459bd388caa2ea106ec01d1ebbe6 +2026-08-31-released-session-format-migrations.zh.md: b06bfac059541e9c397ff46fe7d169690c5b1213 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..eb5eb14f28 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. @@ -105,6 +122,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..b06bfac059 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。 @@ -105,6 +122,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-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/process/2026-09-08-trusted-changed-file-review-routing.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.i18n.yaml similarity index 55% rename from .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.i18n.yaml index 6c9579402e..a3a50d38ef 100644 --- a/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.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 .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md -2026-09-08-trusted-changed-file-review-routing.md: 246c223d976e312e5712d7baa150bd3e84f1363e -2026-09-08-trusted-changed-file-review-routing.zh.md: 397d4174340fc9c6002419b21436b5c33542f7e4 +# 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/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-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-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-25-promote-open-anywhere-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml index 0fa0a4cd7e..2f5a36785b 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.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-25-promote-open-anywhere-plugin.md -2026-08-25-promote-open-anywhere-plugin.md: ee83c424d1454b26c1ce6cf6954105cdbfbb7419 -2026-08-25-promote-open-anywhere-plugin.zh.md: f1696cec10a683d44dcaa3db454d343821fc13c9 +2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f +2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96 diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md index ee83c424d1..83888cb548 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md @@ -12,6 +12,8 @@ The community plugin `@dsh-plugins/open-anywhere` (gitlab.deepseek.com/Ciyou/dsh The first-party feature is named `open-in-app`: it selects the application that opens a workspace directory on the Harness host, not another machine or destination. +The shared `launchedThroughSsh()` predicate in [launch-environment](../../../../packages/util/launch-environment/README.md) reads non-empty `SSH_CONNECTION` or `SSH_TTY` only from the inherited process layer. An SSH launch produces an empty application catalog before any probe. Project and user `.env` values cannot establish an SSH launch; Web browser handoff and the adaptive directory picker use the same predicate. The client hides the action even when it remembers a choice, and the existing availability checks reject icon and launch requests. SSH port forwarding changes HTTP reachability, not which machine owns the workspace or applications. + The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `packages/host/open-in-app/` (the probe, catalog, and launch routes) and `@deepseek-ai/dsh-client-ui-open-in-app` at `packages/client/ui-open-in-app/` (the split button), mounted in the Web profile by the `dsh-web-app` bundle rows `open-in-app` and `ui-open-in-app`. The promotion is a rewrite, not a vendoring: - **A host/client package pair, following the `directory-picker-browse`/`ui-directory-picker-browse` pairing**: the host package's `src/index.ts` registers the three HTTP routes on `ctx.webServer` (`GET /open-in-app/apps`, `GET /open-in-app/icon/`, `POST /open-in-app/open`); the ui package's `src/client/index.ts` registers the split button into `conversation.session.header.utilities` through the standard slot/inject currency, with copy in a typed `open-in-app` locale namespace and styling in CSS Modules over `--dsw-*` tokens (the original's hand-injected style tag and inline dropdown are replaced by the `Menu` primitive), over an empty-apply node half that keeps the plugin on the host roster. Route paths and wire payload types have one home, the host package's browser-safe `./shared` subpath (constants and types only); the client bundle inlines it through an `INLINE_SAFE` entry in the client tsdown preset, the same channel `dsh-session`'s wire slices use. The host root exports only the Loader-required plugin values and types; catalog, resolver, launcher, and icon helpers remain source-internal. @@ -28,6 +30,8 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t ## Alternatives considered +**Offer VS Code's remote CLI during SSH sessions.** Its installed executable does not prove a usable editor connection: the inherited IPC socket belongs to a live VS Code connection and can disappear while Harness keeps running. Browser-side SSH-target configuration and local editor handoff remain outside this host-application feature. + **Vendor the plugin's `lib/` as-is under `packages/`.** Fastest, but the hand-authored JavaScript fails typecheck, coverage, i18n, JSDoc, and invariant gates wholesale; keeping it exempt would create a package class the repository deliberately does not have. **A Typert Remote instead of raw webServer routes.** The apps/open calls fit the Remote RPC shape, but the icon route serves binary PNGs, which the JSON RPC vocabulary does not carry; splitting icons onto a raw route while apps/open ride Remote gives two transports for one feature. Raw routes also match the original's client, and `webhook-github` establishes the validated-raw-route pattern. @@ -50,8 +54,8 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t ## Consequences -- The Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null). +- Outside SSH sessions, the Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null). - The community plugin's install path remains valid but redundant; its original routes and browser choice key are separate from `open-in-app`, so installations using the first-party feature should remove the community plugin to avoid duplicate header controls. - Resolution and icons run lazily, once per host process, so an application installed while dsh runs appears only after restart — accepted; the uninstall direction self-heals through the `ENOENT` single-entry refresh. - The catalog is compile-time fixed; extending it means editing `OPEN_IN_APP_CATALOG` and both locale dictionaries together (README Known Limitations). Platform coverage is uneven — several Git GUIs and terminals are macOS-only entries, Windows icons are limited to the 32px stock .NET extraction, Linux follows hicolor rather than the active theme, and CLI-only entries without a desktop record keep the generic icon. -- Coverage: resolver logic (every locator kind over temp filesystems, registry-dump and desktop-entry fixtures, an injected env/home/PATH table), per-platform icon extraction, the three routes (real Loader + real WebServer composition, including the one-pass cache, the `ENOENT` refresh, and HMR-safety disposal), controller wire behavior, and component presentation are unit-tested to the per-file 100% gate; no snapshot is added because the shipped keyless snapshot fixtures assert session-driven output, which this browser-side control never touches. The web ARIA goldens disable the `open-in-app` and `ui-open-in-app` rows, and the Host-only preset e2e composition disables the host row: the button reflects whatever applications the running machine has installed, so its presence and label are host facts no cross-platform golden can pin. +- Resolver, icon, route, controller, and component tests cover platform discovery, launch outcomes, the availability cache, and HMR disposal. The [SSH Web snapshot](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) renders the shared recorded conversation with both Open In rows enabled and a remembered app choice, capturing only the Session header; composer and statistics output belong to their own snapshots. Inherited SSH markers make the empty catalog deterministic across platforms. Ordinary Web snapshots keep host-dependent application discovery disabled. diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md index f1696cec10..4ef476ce70 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md @@ -12,6 +12,8 @@ Status: implemented 第一方功能命名为 `open-in-app`:它选择在 Harness 主机上打开 workspace 目录的应用,不表示另一台机器或目的位置。 +[launch-environment](../../../../packages/util/launch-environment/README.zh.md) 中共用的 `launchedThroughSsh()` 只从继承的进程层读取非空 `SSH_CONNECTION` 或 `SSH_TTY`。SSH 启动时会在任何探测开始前返回空应用目录。项目与用户 `.env` 中的值不能作为 SSH 启动的依据;Web 浏览器唤起和自适应目录选择器共用此判断。即使客户端记住了应用选择,也会隐藏操作入口;已有的可用性检查会拒绝图标和启动请求。SSH 端口转发只改变 HTTP 可达性,不改变工作区或应用所属的机器。 + 该功能的第一方归属是一对包:`@deepseek-ai/dsh-host-open-in-app` 位于 `packages/host/open-in-app/`(探测、目录与启动路由),`@deepseek-ai/dsh-client-ui-open-in-app` 位于 `packages/client/ui-open-in-app/`(分体按钮),由 `dsh-web-app` bundle 的 `open-in-app` 与 `ui-open-in-app` 两行挂载进 Web profile。转正是重写,不是 vendoring: - **一对 host/client 包,沿用 `directory-picker-browse`/`ui-directory-picker-browse` 的配对结构**:host 包的 `src/index.ts` 在 `ctx.webServer` 上注册三条 HTTP 路由(`GET /open-in-app/apps`、`GET /open-in-app/icon/`、`POST /open-in-app/open`);ui 包的 `src/client/index.ts` 经标准 slot/inject 通货把分体按钮注册进 `conversation.session.header.utilities`,文案在类型化的 `open-in-app` locale 命名空间中,样式为 `--dsw-*` token 上的 CSS Modules(原插件手工注入的 style 标签与内联下拉被 `Menu` 原语替代),节点半边是让插件出现在主机名册上的空 apply。路由路径与 wire 载荷类型只有一个家:host 包浏览器安全的 `./shared` 子路径(只有常量与类型);client bundle 经 client tsdown preset 的 `INLINE_SAFE` 条目将其内联,与 `dsh-session` 各 wire 切片同一通道。host 根入口只导出 Loader 所需的插件实体与类型;目录、resolver、launcher 与图标 helper 保持源码内部可见。 @@ -28,6 +30,8 @@ Status: implemented ## 考虑过的替代方案 +**在 SSH 会话中提供 VS Code 的远端 CLI。** 已安装的可执行文件不能证明编辑器连接可用:继承的 IPC socket 属于一个仍在运行的 VS Code 连接,Harness 继续运行时它也可能消失。浏览器侧的 SSH 目标配置与本地编辑器唤起不属于这个主机应用功能。 + **将插件的 `lib/` 原样 vendor 进 `packages/`。** 最快,但手写 JavaScript 会整体不过 typecheck、覆盖率、i18n、JSDoc 和 invariant 门禁;为其保留豁免会造出仓库刻意不设的包类别。 **用 Typert Remote 而非裸 webServer 路由。** apps/open 调用符合 Remote RPC 形态,但 icon 路由提供二进制 PNG,JSON RPC 词汇承载不了;把 icon 拆去裸路由而 apps/open 走 Remote 会让一个功能有两种传输。裸路由也匹配原插件的客户端,且 `webhook-github` 已确立带校验裸路由的先例。 @@ -50,8 +54,8 @@ Status: implemented ## 后果 -- 只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。 +- 非 SSH 会话中,只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。 - 社区插件的安装路径仍然有效但已冗余;其原始路由与浏览器选择键独立于 `open-in-app`,因此使用第一方功能的安装应移除社区插件,避免出现重复的头部控件。 - 解析与图标每主机进程惰性执行一次,dsh 运行期间安装的应用要重启后才出现——接受;卸载方向经 `ENOENT` 单条目刷新自愈。 - 目录在编译期固定;扩展它意味着同时编辑 `OPEN_IN_APP_CATALOG` 与两份 locale 词典(README 已知限制)。平台覆盖不均——若干 Git GUI 与终端仅有 macOS 条目;Windows 图标受限于 .NET 标准接口的 32px 提取,Linux 跟随 hicolor 而非当前主题,没有 desktop 记录的纯 CLI 条目则保留通用图标。 -- 覆盖:resolver 逻辑(每种 locator 在临时文件系统上、注册表转储与 desktop 条目 fixture、注入的 env/home/PATH 表)、逐平台图标提取、三条路由(真实 Loader + 真实 WebServer 组合,含单趟缓存、`ENOENT` 刷新与 HMR 安全处置)、controller wire 行为和组件呈现都以逐文件 100% 门禁做了单元测试;不新增 snapshot,因为随仓库发布的免密 snapshot fixture 断言会话驱动的输出,而这个纯浏览器侧控件不触及它。Web ARIA golden 禁用 `open-in-app` 与 `ui-open-in-app` 两行,Host-only 的 preset e2e 组合禁用 host 行:按钮反映运行机器实际安装了哪些应用,其出现与否和标签都是主机事实,跨平台 golden 无法钉住。 +- 解析器、图标、路由、控制器与组件测试覆盖平台探测、启动结果、可用性缓存和 HMR 处置。[SSH Web 快照](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) 在启用两个 Open In 配置项并记住应用选择的条件下渲染共享的录制会话,并仅捕获会话头部;输入框和统计栏由各自的快照负责。继承的 SSH 标记使空应用目录在不同平台上保持确定。普通 Web 快照仍禁用依赖主机的应用探测。 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-07-session-prose-local-media-display.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml new file mode 100644 index 0000000000..c52f0a47a2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.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-07-session-prose-local-media-display.md +2026-09-07-session-prose-local-media-display.md: ed740acd0d0d6eaf7f8834dc8d6280a33305aecd +2026-09-07-session-prose-local-media-display.zh.md: de8a7f99d7aabc4474f525f9f37f50a465a80840 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md new file mode 100644 index 0000000000..ed740acd0d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md @@ -0,0 +1,43 @@ +# Agent Note: Session prose local media paths display through a same-origin file route + +Status: implemented + +English | [中文](2026-09-07-session-prose-local-media-display.zh.md) + +## Problem + +Assistant prose can reference an image by its filesystem path, but browsers cannot read Host files. A renderer limited to absolute HTTP(S) destinations leaves those references as inert alt text. Issue #3662 records this display gap. + +## Decision + +Local media paths in Session prose render through a same-origin file route. This note owns the renderer vocabulary and its placement; [authenticated filesystem reads](2026-09-08-file-display-through-filesystem.md) owns the current serving policy and supersedes the workspace/media restrictions described below. + +`ui-primitives` owns the `MarkdownPathImages` vocabulary on `MarkdownText`. Like `fileMentions`, it applies only after a message settles so frozen streaming blocks cannot cache a vocabulary handler. The settled pass rewrites image destinations outside the remote-URL allowlist and emits only absolute `http(s)`, `blob`, or `data` results. Without a vocabulary, local destinations retain inert alt text. Failed loads replace the image with authored alt text, or its original destination when alt is empty; a different source can load again. + +`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `/api/file?path=…` on the page's origin. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert. + +`session-controller` owns the `SessionMediaReferences` contribution beside `SessionFileReferences`. It registers through `connection.fetch`, which applies the same browser authentication and trust checks as `/api` RPC. The fixed same-origin endpoint gives the synchronous renderer a stable URL without an asynchronous capability negotiation. + +## Alternatives considered + +**Typert gateway or workspace controller ownership.** The gateway owns Remote RPC dispatch, while the workspace controller owns registry lifecycle. Neither owns file-byte presentation; Session Controller is the consumer serving Session prose. + +**Session RPC followed by blob/data URLs.** Attachment images can use an asynchronous fetch, but this Markdown vocabulary must synchronously resolve a destination during a memoized render pass. + +**Image-only endpoints.** One file route can serve images, audio, and video without separate URL vocabularies. The current implementation returns complete bounded files; Markdown audio/video player nodes remain independent work. + +**Byte-signature validation in the route.** The model-facing `read_image` tool owns image admission checks. Display responses describe content by MIME lookup and let browser decoding reject corrupt payloads, avoiding a duplicate signature checker. + +**Workspace/media-only access (superseded).** The original policy restricted canonical paths to registered workspace roots and allowed image/video/audio MIME categories except SVG. Regular-file checks before opening rejected pipes and devices; an opened-handle identity comparison narrowed replacement races. These restrictions bounded authenticated access and avoided a per-request interactive authorization flow. They also excluded temporary screenshots and remote files; the successor note records the replacement policy and why those restrictions are not retained. + +## Consequences + +The Client vocabulary cannot bypass Host authentication or the filesystem provider. The original restricted route distinguished an existing outside-workspace path from an absent path, exposing existence even while refusing its bytes; the successor policy instead permits ordinary provider-readable files. + +Windows-style authored paths remain unsupported by the Client vocabulary. Trajectory and tool-card Markdown consumers do not supply this vocabulary, and audio/video Markdown nodes do not render players. These are renderer limitations, independent of the file route's readable MIME types. + +The archived [model-readable image paths](../../archived/feature/2026-08-21-model-readable-image-paths.md) note owns the model-facing behavior; this note owns user-facing display and does not supersede it. + +## Testing + +Renderer tests cover settled and streaming gates, reference-style images, protocol rechecks, failed-load fallback, and replacement sources. Chat tests cover the vocabulary and component wiring. The browser scenario in `apps/web/tests/markdown-images.e2e.ts` boots the shipped Web composition with a seeded Session and checks actual loading and fallback text. A model-driven recorded Session round trip remains separate from this UI expectation; the successor note names current route coverage. diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md new file mode 100644 index 0000000000..de8a7f99d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 会话正文本地媒体路径通过同源文件路由显示 + +Status: implemented + +[English](2026-09-07-session-prose-local-media-display.md) | 中文 + +## Problem + +Assistant 正文可能通过文件系统路径引用图片,但浏览器无法读取 Host 文件。仅允许绝对 HTTP(S) 目标的渲染器会把这些引用保留为静态 alt 文本。Issue #3662 记录了这一展示缺口。 + +## Decision + +Session 正文中的本地媒体路径通过同源文件路由渲染。本记录拥有渲染器词表及其归属;[鉴权文件系统读取](2026-09-08-file-display-through-filesystem.zh.md)拥有当前文件服务策略,并取代下文的工作区与媒体限制。 + +`ui-primitives` 拥有 `MarkdownText` 上的 `MarkdownPathImages` 词表。与 `fileMentions` 一样,它只在消息稳定后生效,使冻结的流式块无法缓存词表处理函数。稳定渲染过程重写远程 URL 白名单之外的图片目标,并只输出绝对 `http(s)`、`blob` 或 `data` 结果。没有词表时,本地目标保留静态 alt 文本。加载失败会把图片替换为作者提供的 alt 文本;alt 为空时显示原始目标路径;不同来源仍可重新加载。 + +`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到页面同源的 `/api/file?path=…`。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。 + +`session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。 + +## Alternatives considered + +**由 Typert gateway 或 workspace controller 拥有。** gateway 拥有 Remote RPC 分发,workspace controller 拥有注册表生命周期。两者都不拥有文件字节展示;Session Controller 是服务 Session 正文的消费方。 + +**先经 Session RPC 获取,再使用 blob/data URL。** 附件图片可以异步获取,但此 Markdown 词表必须在记忆化渲染过程中同步解析目标。 + +**图片专用端点。** 单一文件路由即可服务图片、音频和视频,无需独立 URL 词表。当前实现返回有界完整文件;Markdown 音视频播放器节点仍是独立工作。 + +**路由中的字节签名校验。** 面向模型的 `read_image` 工具拥有图片准入检查。展示响应通过 MIME 查询描述内容,由浏览器解码拒绝损坏载荷,避免重复实现签名检查器。 + +**仅限工作区与媒体的访问(已取代)。** 原策略把规范路径限制在已注册工作区根目录内,并允许除 SVG 外的 image/video/audio MIME 类别。打开前的普通文件检查拒绝管道与设备;已打开句柄的身份比较收窄替换竞态。这些限制约束了鉴权后的访问范围,并避免每次请求的交互授权流程。它们也排除了临时截图与远程文件;后续记录说明替代策略及不保留这些限制的理由。 + +## Consequences + +客户端词表无法绕过 Host 鉴权或文件系统提供方。原受限路由区分了工作区外已存在路径与缺失路径,即使拒绝其字节仍暴露存在性;后续策略则允许提供方可读的普通文件。 + +客户端词表仍不支持作者提供的 Windows 风格路径。轨迹与工具卡片 Markdown 消费方不提供此词表,音视频 Markdown 节点也不渲染播放器。这些属于渲染器限制,与文件路由可读的 MIME 类型无关。 + +已归档的[模型可读图片路径](../../archived/feature/2026-08-21-model-readable-image-paths.md)记录拥有模型侧行为;本记录拥有用户侧展示,不取代它。 + +## Testing + +渲染器测试覆盖稳定与流式门禁、引用式图片、协议复查、加载失败回退和来源替换。聊天测试覆盖词表与组件连接。`apps/web/tests/markdown-images.e2e.ts` 浏览器场景使用已播种 Session 启动交付的 Web 组合,检查实际加载与回退文本。模型驱动的记录 Session 往返仍独立于此 UI 期望;后续记录说明当前路由覆盖。 diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml new file mode 100644 index 0000000000..0817dcd4f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.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-file-display-through-filesystem.md +2026-09-08-file-display-through-filesystem.md: b1f93f8c9fbd245ad69bc732b2a599c6e0abeb0d +2026-09-08-file-display-through-filesystem.zh.md: faa3fdb757c9a3fc60cae37692a559158e02ceee diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md new file mode 100644 index 0000000000..b1f93f8c9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md @@ -0,0 +1,35 @@ +# Agent Note: Authenticated file display reuses filesystem byte reads + +Status: implemented + +English | [中文](2026-09-08-file-display-through-filesystem.zh.md) + +## Problem + +Session prose can reference screenshots in temporary directories or files stored by a remote filesystem provider. A Host-local workspace allowlist cannot serve those paths. An image response without a byte limit can also make the browser download a 1 GiB image before attempting to decode it. + +## Decision + +The authenticated `/api/file` route reads ordinary files through `ctx.fs`. Authentication and the composed provider's read policy govern access; directory and MIME allowlists do not. This supersedes the serving policy in [the local-media display note](2026-09-07-session-prose-local-media-display.md), which retains renderer ownership and its rationale. + +GET calls the existing `readBytes(target, signal, maxBytes)`: providers reject known oversized files before content I/O and enforce the limit while reading. HEAD uses metadata without reading content. `FS_TOO_LARGE` becomes 413. MIME lookup supplies response metadata without sniffing file contents; unknown extensions use `application/octet-stream`. A sandbox CSP prevents directly opened HTML/SVG from executing with the authenticated API origin. + +All files use the resolved `ctx.attachments.imageLimits.maxImageBytes` limit, normally 20 MiB. The attachment service owns this deployment setting. All responses contain complete files; Range is ignored and no range support is advertised. + +## Alternatives considered + +**Workspace and media allowlists.** They limit which authenticated bytes can be read, but exclude ordinary screenshot locations and remote files. The chosen policy permits every regular file the composed provider can read. + +**A new filesystem byte-stream API.** Efficient large-file delivery and audio/video seeking would require implementations in every provider, including remote range handling. Complete bounded reads satisfy the current display scope without widening that interface. Streaming and Range can be added when those use cases justify the provider work. + +**Duplicate size checks in the route.** GET needs no additional stat/read loop: `readBytes` already owns preflight limits, growth detection, and cancellation. HEAD checks size separately because it must not read the body. + +## Consequences + +Temporary and remote files use the same filesystem provider as `read_image`, without adding model-facing events. The local sandbox provider constrains mutations and permits reads; an authenticated client therefore has broader access than registered workspace roots. Files remain subject to the provider's permissions and the route's byte limits. + +Each GET buffers the complete file in Host memory. Audio/video work as complete responses without incremental transfer or guaranteed seeking. Encoded byte limits do not bound decoded pixel dimensions. Failed image loads show authored alt text or the original destination when alt is empty. + +## Testing + +Route tests cover sparse 1 GiB rejection before content I/O, post-stat growth, the shared attachment byte limit, ordinary MIME types, temporary paths and symlinks, opaque remote targets, provider failures, metadata-only HEAD, ignored Range, and disposal. Browser expectations cover rendered images, 413/404 and corrupt-image fallbacks, and an image outside the workspace. Remote byte transfer remains owned by the existing filesystem provider tests. diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md new file mode 100644 index 0000000000..faa3fdb757 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 鉴权文件展示复用文件系统字节读取 + +Status: implemented + +[English](2026-09-08-file-display-through-filesystem.md) | 中文 + +## Problem + +会话正文可能引用临时目录中的截图或远程文件系统提供方中的文件。Host 本地工作区白名单无法提供这些路径。没有字节上限的图片响应还可能让浏览器先下载一张 1 GiB 图片,再尝试解码。 + +## Decision + +鉴权 `/api/file` 路由通过 `ctx.fs` 读取普通文件。鉴权和所组合提供方的读取策略决定访问权限;目录与 MIME 白名单不参与准入。这取代了[本地媒体展示记录](2026-09-07-session-prose-local-media-display.zh.md)中的文件服务策略;该记录保留渲染器归属及其理由。 + +GET 调用现有 `readBytes(target, signal, maxBytes)`:提供方在内容 I/O 前拒绝已知超限文件,并在读取过程中执行上限。HEAD 使用元数据,不读取内容。`FS_TOO_LARGE` 转换为 413。MIME 查询提供响应元数据,不嗅探文件内容;未知扩展名使用 `application/octet-stream`。sandbox CSP 阻止直接打开的 HTML/SVG 以鉴权 API 源身份执行脚本。 + +所有文件均使用已解析的 `ctx.attachments.imageLimits.maxImageBytes` 上限,通常为 20 MiB。附件服务拥有此部署配置。所有响应均包含完整文件;忽略 Range,也不声明支持 Range。 + +## Alternatives considered + +**工作区和媒体白名单。** 它们限制鉴权后能读取哪些字节,却排除了常见截图位置和远程文件。所选策略允许读取所组合提供方可读的任意普通文件。 + +**新增文件系统字节流 API。** 高效的大文件传输和音视频跳转需要每个提供方实现,包括远端 Range 处理。有界完整读取满足当前展示范围,无需扩展该接口。相关用例足以支持这项提供方工作时,可以加入流式传输与 Range。 + +**在路由重复实现大小检查。** GET 无需额外的 stat/read 循环:`readBytes` 已经负责读取前上限、增长检测和取消。HEAD 单独检查大小,因为它不能读取正文。 + +## Consequences + +临时与远程文件使用与 `read_image` 相同的文件系统提供方,不增加模型可见事件。本地沙箱提供方约束变更操作并允许读取,因此鉴权客户端的访问范围大于已注册工作区根目录。文件仍受提供方权限和路由字节上限约束。 + +每个 GET 都会在 Host 内存中缓存完整文件。音视频使用完整响应,不支持增量传输,也不保证跳转播放。编码字节上限不限制解码后的像素尺寸。图片加载失败后展示作者提供的 alt 文本;alt 为空时展示原始目标路径。 + +## Testing + +路由测试覆盖内容 I/O 前拒绝稀疏 1 GiB 文件、stat 后增长、共用附件字节上限、普通 MIME 类型、临时路径与符号链接、不透明远程目标、提供方失败、仅元数据 HEAD、忽略 Range 和释放。浏览器期望覆盖图片渲染、413/404 及损坏图片回退,以及工作区之外的图片。远程字节传输仍由现有文件系统提供方测试负责。 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index 612d81556e..06f10621ec 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-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/process/2026-07-04-doc-tiers-and-budgets.md -2026-07-04-doc-tiers-and-budgets.md: 378da8f8fddafa32dc7450bfac1c5376f2c7a065 -2026-07-04-doc-tiers-and-budgets.zh.md: 1d92ed7fbbec8a9a15bf94a2d320ee88f65a9fa8 +2026-07-04-doc-tiers-and-budgets.md: 209504218d18e97ae6da65bed9a22da40d2a7681 +2026-07-04-doc-tiers-and-budgets.zh.md: 9c866424d84b4fefa5ffe95efa21a3cf7d3c321a diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md index 378da8f8fd..209504218d 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -13,14 +13,14 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m - **Structure follows the documentation tree.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: a document owns detail about its subject, summarizes only the purpose, responsibility, and high-level behavior of direct children, and links to deeper owners. [Agent Notes](../../README.md) remain outside this structural contract. Every human-facing document is a tutorial with an ordered outcome or a reference with an explicit lookup scope; a [postmortem](../../../../docs/postmortem/README.md) is an incident-scoped reference whose chronology records evidence. Tutorials introduce concepts in prerequisite order for the reader's starting knowledge. - **A tier taxonomy with one home per fact.** The standard assigns every Markdown tier one job, forbids restating a fact outside its home tier, and carries the slop checklist used when writing or reviewing any doc. - **One product onboarding path.** The root README owns the recommended package-run path, the source-run alternative, and compact `dsh plugin --profile` usage. The published user guide starts with tasks inside the running Web UI, then links to distinct tutorials or reference owners for other interfaces, plugin development, and advanced configuration instead of repeating Web startup. -- **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them. +- **Narrow, hard budget gates.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Its scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and complete package READMEs remain unbudgeted because exhaustive facts can be long. The separate [package Summary gate](../../../../scripts/verify-package-readme-summaries.ts) caps only each English package entry paragraph at 100 words and directs failures to `dsh-doc` and the selected kind template. - **Ceilings are an enforcement frontier that ratchets.** A doc at or below its target keeps at least 5% headroom as its ceiling ratchets down; a doc above target keeps a frozen ceiling that prevents growth until it reaches the target (root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600 except `packages/AGENTS.md` ≤ 650 and `docs/AGENTS.md` ≤ 1,250; `packages/README.md` ≤ 600). When the gate goes red, relocate or condense; raise a ceiling only with explicit PR justification. - **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc](../../../skills/dsh-doc/SKILL.md) carries the placement, audit, budget, and website workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) over the i18n contract. ## Alternatives considered - **Skill and review discipline without a gate** — rejected: the accretion above happened while the current-state rule and reviewer attention already existed; a prose rule with no mechanical backstop demonstrably does not hold here, and this repo's own [quality-gates stance](2026-06-11-quality-gates.md) says invariants worth keeping are worth encoding. -- **A broad gate over every doc tier** — rejected: a blanket ceiling punishes exactly the right kind of long doc (a feature matrix or type catalog where every row is a fact) and generates per-file override churn that trains contributors to rubber-stamp raises. +- **A broad gate over every complete doc** — rejected: a blanket ceiling punishes exactly the right kind of long doc (a feature matrix or type catalog where every row is a fact) and generates per-file override churn that trains contributors to rubber-stamp raises. The package Summary limit instead bounds one common entry paragraph without constraining its owning reference sections. - **Independent onboarding tutorials for each documentation entry point** — rejected: duplicated setup steps drift in command order, first outcome, and product identity. A short README path followed by task-focused guides keeps the transition explicit without maintaining competing tutorials. - **Housing the standard inside the skill** — rejected: contracts live in docs and workflows in skills; a standard packed into SKILL.md is invisible to an agent that edits docs without invoking the skill, and `docs/AGENTS.md` already loads as subtree instructions for anyone working under `docs/`. @@ -30,4 +30,5 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m - Structural review starts with ownership and document form before sentence-level editing, so lower-level detail moves to its owner instead of being polished in the wrong place. - Readers reach a running Web UI before encountering headless execution, SDK embedding, custom profiles, or direct settings files; those interfaces remain available from their reference owners. - Budgeted docs that remain above target cannot grow; reaching the target restores the 5% working headroom. +- Package references retain exhaustive owned facts below their entry paragraph, while every package Summary stays within the same 100-word retrieval budget. - Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly. diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index 1d92ed7fbb..9c866424d8 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -13,14 +13,14 @@ Status: implemented - **结构遵循文档树。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:文档负责承载其主题的详细内容,仅概述直接子项的目的、职责和高层行为,并链接到更深层内容的归属文档。[Agent Note](../../README.zh.md) 仍不受这一结构约定约束。每份面向人的文档要么是按顺序引导读者达成结果的教程(tutorial),要么是查阅范围明确的参考文档(reference);[事故复盘(postmortem)](../../../../docs/postmortem/README.zh.md) 是范围限定于单起事故的参考文档,其时间线记录证据。教程结合读者的起始知识,按前置依赖顺序介绍概念。 - **每项事实只归属一处的层级分类。**文档标准为每种 Markdown 层级分配单一职责,禁止在事实归属层级之外重复陈述,并包含编写或评审任何文档时使用的赘余检查清单。 - **单一产品入门路径。**根 README 负责推荐的包运行路径、从源码运行的备选路径和简要的 `dsh plugin --profile` 用法。已发布的用户指南从运行中的 Web UI 内部任务开始,再链接到其他界面的独立教程或插件开发与进阶配置的参考文档归属处,而不会重复介绍 Web 启动步骤。 -- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其词数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们。 +- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其词数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。该门禁的范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和完整的包 README 仍不设预算,因为穷尽式事实可能很长。单独的[包 Summary 门禁](../../../../scripts/verify-package-readme-summaries.ts)只把每个英文包入口段落限制为 100 词,并引导失败项阅读 `dsh-doc` 和所选 kind 模板。 - **上限是只进不退的执行红线。** 达到或低于目标的文档在上限逐步下调时保留至少 5% 的余量;高于目标的文档则维持冻结的上限,在达到目标之前不得增长(根 `AGENTS.md` ≤ 1,600 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600,但 `packages/AGENTS.md` ≤ 650、`docs/AGENTS.md` ≤ 1,250;`packages/README.md` ≤ 600)。门禁变红时,迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才提高上限。 - **精简的工作流 skill(技能),约定归文档。**[.agents/skills/dsh-doc](../../../skills/dsh-doc/SKILL.md) 承载文档放置、审计、预算与站点发布工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 约定之间的分工相同。 ## 曾考虑的替代方案 - **仅靠 skill 和评审纪律,不设门禁**:否决。上述膨胀正是在现行规则和评审注意力已经存在的情况下发生的;一条没有自动化保障的行文规则在此处已被证明无法维持,而本仓库自身的[质量门禁立场](2026-06-11-quality-gates.zh.md)认为值得保持的不变式就值得编码。 -- **对所有文档层级全面设限**:否决。一刀切的上限恰好惩罚了那些正当的长文档(如功能矩阵或类型目录,每一行都是事实),并产生逐文件的例外变更,训练贡献者机械地批准提限。 +- **对每份完整文档全面设限**:否决。一刀切的上限恰好惩罚了那些正当的长文档(如功能矩阵或类型目录,每一行都是事实),并产生逐文件的例外变更,训练贡献者机械地批准提限。包 Summary 上限只约束共同的入口段落,不限制其归属参考章节。 - **为每个文档入口维护独立入门教程**:否决。重复的设置步骤会在命令顺序、首个结果和产品定位上产生分歧。简短的 README 路径接上面向任务的指南,可明确衔接两者,且不需要维护相互竞争的教程。 - **将标准放在 skill 内部**:否决。约定归文档,工作流归 skill;如果标准被塞进 SKILL.md,那些不调用该 skill 而直接编辑文档的 agent(智能体)就看不到它,而 `docs/AGENTS.md` 已经作为子树指令被任何在 `docs/` 下工作的人加载。 @@ -30,4 +30,5 @@ Status: implemented - 结构评审先检查归属关系和文档形式,再进行句子层面的编辑,使较低层级的细节迁移到其归属文档,而不是在错误的位置加以润色。 - 读者会先进入可运行的 Web UI,再遇到 headless 执行、SDK 嵌入、自定义 profile 或直接 settings 文件;这些入口仍可从各自的参考文档归属处访问。 - 仍高于目标的受预算约束文档不得增长;达到目标后,将恢复 5% 的工作余量。 +- 包参考可在入口段落之后保留穷尽式归属事实,而每个包 Summary 都遵守相同的 100 词检索预算。 - 词数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻。 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-08-unified-github-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml index 620dbfc5ad..8c6982cd71 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.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-unified-github-label-taxonomy.md -2026-08-08-unified-github-label-taxonomy.md: 625c5c1cac951bdc97187c17c964d677f31131c7 -2026-08-08-unified-github-label-taxonomy.zh.md: 855a2b98f44d517abe1f7718ae4e81262cb031b6 +2026-08-08-unified-github-label-taxonomy.md: 1748f9b77ed2922035c5e75ac4a2eee047f413d3 +2026-08-08-unified-github-label-taxonomy.zh.md: 3b221a5f3db43597656f46dfc505cdfda38c75db diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md index 625c5c1cac..1748f9b77e 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md @@ -47,6 +47,8 @@ The area set is intentionally extensible. When no existing description honestly Issues use native Issue Type instead of `kind/*`; their `area/*` labels remain optional. `source/*` labels record how an Issue was created and do not apply to pull requests. Priority, GitHub defaults, and workflow triggers remain independent operational metadata. +The repository lifecycle removes pull request `kind/*` labels and reserved aliases from an Issue before auditing it. Policy comments report only violations whose intended value cannot be derived from the Issue, such as a missing native Type or an unsupported Priority. + Label migrations preserve meaning before removing aliases: add the canonical replacement, verify the labelable, then remove the obsolete assignment. A label is deleted only after no pull request or Issue still uses it, and unrelated labels are never replaced as a set. ## Alternatives considered @@ -65,8 +67,10 @@ Label migrations preserve meaning before removing aliases: add the canonical rep **Kinds on Issues.** Native Issue Type already owns that classification; duplicating it as a label creates drift. +**Comment-only Issue enforcement.** A comment preserves invalid metadata and requires human cleanup even when the only valid result is removal. The lifecycle applies that removal and retains comments for choices it cannot infer. + **Exactly one area per pull request.** Coherent changes can materially affect several independent APIs or behaviors, and dropping secondary areas hides affected scope. ## Consequences -Reviewers and automation can query intent, semantic scope, how an Issue was created, priority, and operational triggers independently. Maintainers must read the change and the live label descriptions instead of inferring classification from title prefixes or paths. The live catalog, this rationale, and policy enforcement must move together when a kind or a non-obvious area boundary changes, and taxonomy migrations carry an explicit historical backfill and verification cost. +Reviewers and automation can query intent, semantic scope, how an Issue was created, priority, and operational triggers independently. Invalid Issue labels disappear without a policy comment, and the label event records the repair; when no other violation remains, the lifecycle deletes any earlier policy comment. Maintainers must read the change and the live label descriptions instead of inferring classification from title prefixes or paths. The live catalog, this rationale, and policy enforcement must move together when a kind or a non-obvious area boundary changes, and taxonomy migrations carry an explicit historical backfill and verification cost. diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md index 855a2b98f4..3b221a5f3d 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md @@ -47,6 +47,8 @@ Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对 Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然可选。`source/*` 标签记录 Issue 的创建方式,不适用于 PR。优先级、GitHub 默认标签和工作流触发器仍是相互独立的管理元数据。 +仓库生命周期会先从 Issue 中移除 PR `kind/*` 标签和保留别名,再执行审计。政策评论只报告无法从 Issue 推导预期值的违规项,例如缺失原生 Issue Type 或使用不受支持的优先级。 + 迁移标签时,须先保留语义,再移除别名:先添加规范替代标签,核验可加标签对象,再移除废弃的标签关系。只有在所有 PR 和 Issue 都不再使用某个标签后才能将其删除,且绝不整组替换无关标签。 ## 考虑过的替代方案 @@ -65,8 +67,10 @@ Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然 **在 Issue 上使用类型标签。** 原生 Issue Type 已负责这项分类;再用标签复制会造成漂移。 +**仅用评论执行 Issue 政策。** 评论会保留无效元数据;即使唯一有效结果是移除,仍要求人工清理。生命周期会直接执行这类移除,只对无法推断的选择保留评论。 + **每个 PR 恰好一个领域。** 内聚的变更可能对多个独立 API 或行为产生实质影响,丢弃次要领域会隐藏受影响范围。 ## 后果 -评审人和自动化流程可以分别查询意图、语义范围、Issue 的创建方式、优先级和工作流触发条件。维护者必须阅读变更内容和现行标签说明,而不能根据标题前缀或路径推断分类。当某种类型或某条非显然的领域边界发生变化时,现行标签清单、本记录中的决策依据和政策执行必须同步更新;分类体系迁移还会产生明确的历史回填和验证成本。 +评审人和自动化流程可以分别查询意图、语义范围、Issue 的创建方式、优先级和工作流触发条件。无效的 Issue 标签会直接消失,不会产生政策评论;标签事件会记录该修复。如果不存在其他违规项,生命周期会删除更早的政策评论。维护者必须阅读变更内容和现行标签说明,而不能根据标题前缀或路径推断分类。当某种类型或某条非显然的领域边界发生变化时,现行标签清单、本记录中的决策依据和政策执行必须同步更新;分类体系迁移还会产生明确的历史回填和验证成本。 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..5edb76739c 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: c403964d8de1c158e5949b5e112a038832709874 +2026-08-10-npm-release-sequences.zh.md: d03da4c4485c4807497dfb28b61ab342bb4c8d12 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..c403964d8d 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. 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..d03da4c448 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。 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-09-08-comment-only-review-routing.i18n.yaml b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml new file mode 100644 index 0000000000..b32260c5e7 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.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-comment-only-review-routing.md +2026-09-08-comment-only-review-routing.md: 050905285b2291b34da9873d19c2f122c088a9e5 +2026-09-08-comment-only-review-routing.zh.md: b98f5d70b4d5c0fd27df1c393238b0802e420e09 diff --git a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md new file mode 100644 index 0000000000..050905285b --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md @@ -0,0 +1,41 @@ +# Agent Note: Exclude documentation and comment-only changes from review routing + +Status: implemented + +English | [中文](2026-09-08-comment-only-review-routing.zh.md) + +## Problem + +Directory ownership alone treats documentation and comment edits like executable changes. These edits do not require the automatic code-owner request that protects behavior changes. + +GitHub may omit or truncate a file patch. A scanner that assumes every patch is complete can miss executable changes that occur outside the supplied hunks. + +## Decision + +Review routing classifies every old and new path in this order: test, documentation, comment-only, then reviewable code. Test classification wins when a test path also has a documentation extension. Every filename ending in `.md` or `.yaml`, matched without case sensitivity, is documentation. A `.yml` file is not documentation under this rule. + +Comment-only classification applies only to files with `status: modified` and a declared source-comment syntax. The scanner reconstructs the before and after text for each patch hunk, removes comments outside quoted strings, removes empty lines left by comments, and requires the remaining text to be identical. + +The scanner counts added and deleted patch lines and compares them with GitHub's file record before accepting a comment-only result. A missing patch, a count mismatch, a rename, an unsupported extension, or a comment form that remains visible to the lexer keeps the file reviewable. This fail-safe result can request an unnecessary review but cannot suppress a known code change. + +The supported lexical rules cover C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for an explicit extension set in the scanner. Comment directives such as JSDoc tags, lint controls, compiler controls, and coverage controls are comments for routing purposes. + +## Verification + +[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover documentation extensions, supported comment forms, quoted comment markers, executable token changes, incomplete patches, renames, unsupported extensions, exclusion precedence, and the no-request result when every file is excluded. + +## Alternatives considered + +**Keep every non-test file reviewable.** This requests code owners for documentation and comment maintenance even though the routing policy is intended to identify executable changes. + +**Infer arbitrary semantic equivalence.** Proving behavior equivalence across the repository's languages requires language toolchains and still cannot assign one stable meaning to generated files, configuration, or build directives. The scanner performs only lexical comment removal. + +**Trust every patch returned by GitHub.** GitHub can omit or truncate patches. Matching the patch's added and deleted line counts to the file record prevents a partial patch from producing a comment-only verdict. + +**Fetch and parse every complete file revision.** Per-file content requests multiply API traffic for large pull requests and still require the same language-specific parsing. The changed-file response already carries enough evidence for complete ordinary patches. + +## Consequences + +Documentation and proven comment-only changes request nobody. The workflow logs them separately from tests so maintainers can audit why owner matching ignored a file. + +Unsupported or incomplete inputs remain reviewable. Comment directives do not request owners even when another tool interprets them, because this policy classifies their lexical form rather than downstream tool behavior. diff --git a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md new file mode 100644 index 0000000000..b98f5d70b4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 从评审路由中排除文档和纯注释变更 + +Status: implemented + +[English](2026-09-08-comment-only-review-routing.md) | 中文 + +## 问题 + +只按目录分配 owner 会把文档和注释编辑视为可执行变更。这些编辑不需要用于保护行为变更的自动代码 owner 请求。 + +GitHub 可能省略或截断文件 patch。如果扫描器假定每个 patch 都完整,就可能漏掉位于已提供 hunk 之外的可执行变更。 + +## 决策 + +评审路由按测试、文档、纯注释、可评审代码的顺序对每个新旧路径分类。当测试路径同时具有文档扩展名时,测试分类优先。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,扩展名匹配不区分大小写;此规则不把 `.yml` 文件视为文档。 + +纯注释分类只适用于 `status: modified` 且已声明源码注释语法的文件。扫描器重建每个 patch hunk 的变更前后文本,移除引号字符串外的注释和注释留下的空行,并要求其余文本完全相同。 + +扫描器会统计 patch 的新增行和删除行,并在接受纯注释结果前与 GitHub 文件记录比较。缺失 patch、计数不符、重命名、不受支持的扩展名,或词法分析器仍能看到的注释形式都会使文件保持可评审状态。该保守结果可能产生不必要的评审请求,但不会隐藏已知代码变更。 + +受支持的词法规则按扫描器中显式的扩展名集合覆盖 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释及 HTML 注释。JSDoc 标签、lint 控制、编译器控制和覆盖率控制等注释指令在评审路由中仍属于注释。 + +## 验证 + +[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖文档扩展名、受支持的注释形式、引号内的注释标记、可执行 token 变更、不完整 patch、重命名、不受支持的扩展名、排除优先级,以及所有文件均被排除时不发出请求的结果。 + +## 考虑过的替代方案 + +**让每个非测试文件都保持可评审。** 这会为文档和注释维护请求代码 owner,但该路由策略的目标是识别可执行变更。 + +**推断任意语义等价。** 证明仓库中多种语言的行为等价需要各语言工具链,而且仍然无法为生成文件、配置或构建指令提供一种稳定含义。扫描器只执行词法注释移除。 + +**信任 GitHub 返回的每个 patch。** GitHub 可能省略或截断 patch。将 patch 的新增和删除行数与文件记录匹配,可以防止不完整 patch 产生纯注释结论。 + +**获取并解析每个文件的完整修订版本。** 对于大型 PR,逐文件内容请求会增加多倍 API 流量,而且仍需相同的语言专用解析。普通完整 patch 所需的证据已包含在变更文件响应中。 + +## 后果 + +文档和确认的纯注释变更不会请求任何人。Workflow 会将它们与测试分开记录,以便维护者检查 owner 匹配忽略文件的原因。 + +不受支持或不完整的输入仍需评审。即使其他工具会解释注释指令,这些指令也不会请求 owner,因为该策略按词法形式分类,而不是按下游工具行为分类。 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 246c223d97..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 @@ -2,8 +2,6 @@ Status: implemented -English | [中文](2026-09-08-trusted-changed-file-review-routing.zh.md) - ## Problem GitHub's native CODEOWNERS behavior requests reviewers whenever a matching path changes. It cannot apply this repository's distinction between reviewable implementation or documentation files and test-only evidence. A native CODEOWNERS file also makes GitHub, rather than an inspected repository program, responsible for the request decision. @@ -12,21 +10,25 @@ Review routing needs an observable changed-file input, explicit owner rules, com ## Decision -The repository keeps a CODEOWNERS-compatible map at [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS), outside GitHub's native CODEOWNERS locations. The map accepts only explicit absolute directory patterns and individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, duplicate patterns, and duplicate owners. Later matching patterns replace earlier matches. +The repository keeps a CODEOWNERS-compatible map at [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS), outside GitHub's native CODEOWNERS locations. The map accepts only explicit absolute directory patterns with one or two individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, more than two owners, duplicate patterns, and duplicate owners. Later matching patterns replace earlier matches. The policy test counts non-test tracked lines in directories that match an ownership rule. It rejects a map in which `@turtle1999` owns more than one third of that eligible owned codebase. -The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on non-draft `pull_request_target` events for opened, synchronized, reopened, and ready-for-review pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets. +The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on `pull_request_target` events for opened, synchronized, reopened, ready-for-review, and converted-to-draft pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets. The scanner fetches every changed-file record before deciding. It fails if the pull request reports more than GitHub's 3,000-file API limit or if pagination returns an incomplete list. It normalizes repository paths, evaluates old and new paths of a rename independently, and escapes filenames before logging them. -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 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 non-test paths, excluded test paths, per-file owner matches, and final reviewer list before any review-request mutation. It requests the union of matched individual owners after removing the pull-request author and users who are already requested. A test-only or wholly unmatched change requests nobody. +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 test convention, production-name negative controls, renames, last-match behavior, unmatched files, complete pagination, the 3,000-file limit, log-before-request ordering, author and existing-reviewer filtering, test-only changes, drafts, 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 @@ -36,12 +38,18 @@ The workflow prints the changed non-test paths, excluded test paths, per-file ow **Execute the pull request's scanner or owner map under `pull_request_target`.** This lets an untrusted pull request choose its own write-capable behavior or owners. -**Infer semantic source changes from patches or language parsers.** GitHub can truncate patches, and the repository spans TypeScript, JavaScript, Python, Rust, YAML, Markdown, and generated evidence. A cross-language semantic classifier would add ambiguous rules without providing a complete input. The scanner therefore uses the complete non-test changed-file list and does not claim to distinguish formatting, comments, or documentation-only edits inside an eligible file. +**Select capped candidates by login order.** Login order is stable but ignores how much reviewable code changed under each owner's directories. Changed LOC makes the limited requests follow the pull request's strongest ownership relevance while retaining login order for ties. + +**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 requests are reproducible from a trusted policy and the file list printed in the workflow log. Test-only changes do not request owners. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself. +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 every matched owner rather than choosing one owner nondeterministically. Shared ownership on large directories therefore produces multiple requests. 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 non-test change under an owned directory remains eligible, including comment-only or formatting-only edits and documentation changes. Unmatched paths are logged and request nobody. Pull requests above the API file limit fail without requesting a partial owner set. +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/process/2026-09-08-trusted-changed-file-review-routing.zh.md b/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md deleted file mode 100644 index 397d417434..0000000000 --- a/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: 基于受信任的变更文件策略路由评审 - -Status: implemented - -[English](2026-09-08-trusted-changed-file-review-routing.md) | 中文 - -## 问题 - -只要匹配路径发生变更,GitHub 原生 CODEOWNERS 就会请求评审者。它无法应用本仓库对需评审的实现或文档文件与纯测试证据的区分。使用原生 CODEOWNERS 文件还会让 GitHub 负责请求决策,而不是由可检查的仓库程序负责。 - -评审路由需要可观测的变更文件输入、显式 owner 规则、完整的测试排除规则,以及对 fork PR 仍然安全且具备写权限的 workflow。 - -## 决策 - -仓库在 GitHub 原生 CODEOWNERS 路径之外的 [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS) 中保存兼容 CODEOWNERS 格式的映射。该映射只接受显式绝对目录模式和 GitHub 个人用户。通配符、隐藏目录模式、团队、重复模式和重复 owner 都会被拒绝。靠后的匹配模式会替换靠前的匹配结果。 - -策略测试会统计匹配所有权规则的目录中的非测试跟踪文件行数。如果 `@turtle1999` 拥有的有效代码库超过三分之一,测试就会拒绝该映射。 - -[`request-review` workflow](../../../../.github/workflows/request-review.yml) 在非草稿 PR 的 `pull_request_target` 事件上运行,订阅创建、同步、重新打开和标记为可评审操作。具备写权限的 job 检出默认分支,只执行默认分支上的扫描器和所有权映射。它不会检出 PR 代码,也不会读取仓库 secret。 - -扫描器在决策之前获取所有变更文件记录。如果 PR 报告的文件数超过 GitHub API 的 3,000 个文件上限,或者分页只返回了部分列表,扫描器就会失败。它会规范化仓库路径,分别检查重命名前后的路径,并在记录文件名之前进行转义。 - -扫描器会在匹配 owner 之前排除纯测试路径。排除范围包括名为 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 或 `stress-tests` 的目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,以 `.bench.`、`.corpus.`、`.e2e.`、`.perf.`、`.snapshot.`、`.spec.`、`.stress.` 或 `.test.` 结尾的文件名,以及 Python 的 `test_*.py`、`*_test.py` 或 `*_tests.py` 文件。`vitest*.config.ts` 和门禁实现等测试基础设施仍需评审,因为它们会改变仓库证据的生成方式。 - -Workflow 会在发出任何评审请求变更之前,依次打印变更的非测试路径、排除的测试路径、逐文件 owner 匹配结果和最终评审者列表。它合并匹配到的个人 owner,并排除 PR 作者和已经收到评审请求的用户。纯测试变更或全部未匹配的变更不会请求任何人。 - -## 验证 - -[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖允许的所有权语法、拒绝的语法、每种测试约定、生产文件名负向对照、重命名、最后匹配规则、未匹配文件、完整分页、3,000 个文件上限、先记录后请求的顺序、作者与现有评审者过滤、纯测试变更、草稿和 API 失败。[Workflow 测试](../../../../scripts/ci-workflow.spec.ts)固定事件集合、最小权限、受信任的默认分支检出、不引用 PR head 和 secret,以及执行的命令。门禁图在静态 CI 和 `check-all` 中包含这两组测试。 - -## 考虑过的替代方案 - -**使用原生 CODEOWNERS。** 原生路由无法忽略纯测试变更,也无法在请求评审者之前提供由仓库控制的决策日志。 - -**在 `pull_request` 下运行并检出 PR head。** Fork workflow 无法获得具备写权限的 token,而向不受信任 head 中的代码授予写权限 token 并不安全。 - -**在 `pull_request_target` 下执行 PR 中的扫描器或 owner 映射。** 这会让不受信任的 PR 选择自己的写权限行为或 owner。 - -**根据补丁或语言解析器推断语义源码变更。** GitHub 可能截断补丁,而且仓库包含 TypeScript、JavaScript、Python、Rust、YAML、Markdown 和生成的证据。跨语言语义分类器会增加含义不明确的规则,却无法提供完整输入。因此,扫描器使用完整的非测试变更文件列表,并且不会声称能够区分合格文件中的纯格式、注释或仅文档编辑。 - -## 后果 - -评审请求可以根据受信任的策略和 workflow 日志中打印的文件列表复现。纯测试变更不会请求 owner。所有权变更只有合并后才会生效,因此修改策略的 PR 无法对自身应用其中不受信任的策略。 - -Workflow 会请求所有匹配的 owner,不会随机选择一人。因此,大目录上的共享所有权会产生多个请求。GitHub 使用 `GITHUB_TOKEN` 生成的评审请求事件可能不会启动依赖递归触发事件的其他 workflow;这些 workflow 不得把此请求作为唯一触发条件。 - -所有已分配目录下的非测试变更仍符合请求条件,其中包括纯注释、纯格式调整和文档变更。未匹配的路径会被记录,但不会请求任何人。超过 API 文件上限的 PR 会失败,并且不会请求不完整的 owner 集合。 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..413d0f32b5 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: d5447f93666a5cb27af2073a4a66f99d0c39f6ee +2026-09-04-session-open-performance-gate.zh.md: 657f77fb76cf84afc36301d22d9a7798c559075b 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..d5447f9366 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. 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..657f77fb76 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 或执行历史仓库。预算是源码中的受评审常量,不由环境变量覆盖。 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..de9078ed58 --- /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: 8ea0a5892d78eda16e657334dba2af58b6648d09 +2026-09-08-ci-readiness-and-completion.zh.md: 62a4c64081369a20a576805fb8a465bffff2922d 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..8ea0a5892d --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md @@ -0,0 +1,33 @@ +# 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. + +## 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 [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..62a4c64081 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md @@ -0,0 +1,33 @@ +# 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 秒测试预算内另设五秒和三秒限制。这些用例验证发布内容与取消行为,不衡量冷启动延迟。 + +## 决策 + +[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 和目录。 + +[子 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/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml index 18f7e242ba..5f57698abb 100644 --- a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml +++ b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.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/proposed/process/2026-08-20-audience-first-documentation-quality.md -2026-08-20-audience-first-documentation-quality.md: d44e9508959232397b90ad8a22a5e8b6040e0748 -2026-08-20-audience-first-documentation-quality.zh.md: 88c0c64266eed9a0744b43185362342638e4a6b9 +2026-08-20-audience-first-documentation-quality.md: 9e0c4a61408449100b79148a571cd740e4044040 +2026-08-20-audience-first-documentation-quality.zh.md: 0b6f1da54380f1d2d44afbe948131233def513cd diff --git a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md index d44e950895..9e0c4a6140 100644 --- a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md +++ b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md @@ -51,7 +51,7 @@ Adopt one audience-first quality contract with five definitions: The [dsh-doc skill](../../../skills/dsh-doc/SKILL.md) owns the first executable version of these rules. The `session-persistence-jsonl` README pair uses the shipped append, recovery, and encoding behavior as evidence rather than treating its prior prose as authority. - Every authored package README starts with searchable YAML. A Skill-style `description` and mechanically derived `kind` are required. Four kinds map one-to-one to four skill templates: `package-group` (group map), `package-reference` (plugin or service package), `package-library` (plain module entry), and `package-bundle` (`dsh.bundle.patch`). The counterpart path, hashes, and physical line alignment belong to the merge-safe sidecar and its gate, so README frontmatter contains no `i18n` block. The title or package manifest already owns the name, the document job expresses its audience, and tags remain absent until a governed taxonomy and search consumer proves value beyond full-text search. -- Authored pages start with a three-to-five-sentence `Summary`, then a linked `Table of Contents`. Format-owned Agent Notes, postmortems, generated fragments, and machine files keep their required skeletons. +- Authored pages start with a three-to-five-sentence `Summary`, then a linked `Table of Contents`. An English package README Summary stays within 100 `wc -w`-style words. It describes reader-visible capability instead of Cordis roles, registrations, or internal components, and omits source identifiers unless readers use them directly in configuration, commands, or a public API. Format-owned Agent Notes, postmortems, generated fragments, and machine files keep their required skeletons. - Each substantive section starts with a short orientation before subsections, tables, or code, and the page progresses from basic user use to advanced developer and maintainer detail. - English technical prose uses an ASD-STE100-inspired, non-certified clarity review: explicit actors and actions, stable terms, direct verbs, separated instructions and conditions, and preserved modality, exceptions, timing, and numbers. The 20-word instruction and 25-word description limits are review prompts. Precision overrides them. - Package contracts remain beside code. Cross-package material moves deliberately toward `docs/learn/overview/`, `docs/learn/cordis/`, `docs/learn/practices/`, `docs/user/`, `docs/developer/`, `docs/developer/discussion/`, `docs/scratch/`, and the parallel `docs/subsystems/` tier. @@ -87,11 +87,11 @@ The first prototype should use one large catalog and one mixed subsystem page. I 1. Create and validate `dsh-doc`, then rewrite one package README pair as a line-aligned, metadata-bearing prototype without changing runtime claims. 2. Review the rendered prototype with newcomer, user, developer, and agent tasks; revise the skill before enforcing the format elsewhere. -3. Add narrow metadata, section-order, line-alignment, link-resolution, and pairing fixtures. Keep sidecars until every merge and recovery consumer has replacement support. +3. Add narrow metadata, Summary-length, section-order, line-alignment, link-resolution, and pairing fixtures. Migrate every existing package Summary that violates the accepted entry limit, and keep sidecars until every merge and recovery consumer has replacement support. 4. Extract accepted standing rules into one canonical quality reference, condense `docs/AGENTS.md` below its target, and organize one coherent `docs/` topic at a time with atomic link/navigation repair. 5. Prototype generated-reference entry/detail separation on `config-catalog.md` and `docs/subsystems/core.md`; apply confirmed patterns elsewhere only after measured lookup cost falls without lost facts or route churn. -This sequence keeps each change independently reviewable. The first three slices improve criteria and correctness without rewriting the corpus; the generated-doc prototype supplies evidence before a broader information-architecture change. +This sequence keeps each change independently reviewable. The first three slices improve criteria and package entry points without changing the broader information architecture; the generated-doc prototype supplies evidence before a broader structural change. Slices 1–3 have shipped in this form: `dsh-doc` is the consolidated standard (`dsh-doc-standards` and `dsh-doc-site-sync` are folded into it, and the site workflow carries the corrected sidebar values), the `session-persistence-jsonl` README pair is the reference example, and `pnpm run test:docs` enforces the metadata, pairing, and quick documentation checks. Slices 4–5 remain open. @@ -107,7 +107,7 @@ This proposal does not shorten exhaustive facts, merge audience tiers, publish i **Use readability scores as the quality gate.** Rejected because formulas penalize exact technical terms and cannot detect wrong ownership, missing failure behavior, stale commands, or a broken reader journey. -**Rewrite or split the full corpus immediately.** Rejected because the current system is mechanically healthy and many long references are appropriately exhaustive. A prototype should prove a retrieval improvement before route and translation churn spreads. +**Rewrite or split the full documentation corpus immediately.** Rejected because the current system is mechanically healthy and many long references are appropriately exhaustive. The bounded package-Summary migration does not alter routes or exhaustive reference content; larger structural changes still require measured evidence. **Keep the existing gates and rely on review for friendliness.** Rejected because the stale workflow values and budget-policy mismatch show that review alone does not preserve copied semantic claims, and the current gates do not ask whether a reader can complete a task. @@ -116,6 +116,7 @@ This proposal does not shorten exhaustive facts, merge audience tiers, publish i - One canonical quality reference defines brief, intuitive, friendly, accurate, and agent-readable documentation by document job. - `.agents/skills/dsh-doc` validates and directly links its metadata, structure/hierarchy, and review/prototype references without duplicating their detailed rules in `SKILL.md`. - The `session-persistence-jsonl` README pair demonstrates searchable YAML, Summary, Table of Contents, user-to-developer progression, Further Exploration, final Dev Note, structural parity, and exact line-count equality while preserving verified package contracts. +- Every English package README Summary stays within 100 `wc -w`-style words; the focused gate reports the measured count and directs failures to `dsh-doc` and the selected kind template. - `docs/AGENTS.md` links that reference, remains sufficient as standing instruction, and is below its target with at least 5% headroom. - The root user path, Web quick start, first-plugin tutorial, contributor setup, and architecture overview each name an observable outcome and a verification owner without duplicating implementation detail. - The budget manifest records both target and temporary ceiling, and its check reports or rejects a violated headroom/ratchet state. @@ -128,7 +129,7 @@ This proposal does not shorten exhaustive facts, merge audience tiers, publish i ## Risks - Metadata can become boilerplate; the package README check therefore permits only fields with current retrieval, template-selection, or bilingual-consistency consumers. -- Hard sentence limits can fragment explanations or separate a condition from its consequence. The controlled-English word counts remain review prompts, and exact contracts override them. +- Hard sentence limits can fragment explanations or separate a condition from its consequence. The controlled-English sentence counts remain review prompts, while the separate 100-word package-Summary ceiling bounds only the entry paragraph and leaves exact contracts in the owning sections. - Exact line alignment can pressure translators into unnatural prose; review must protect meaning and may revise both sides together rather than weaken one. - Splitting generated references can increase routes and link maintenance; prototypes must preserve aliases and measure the trade-off. - A semantic check can become a repository-topology scanner that blocks legitimate changes; checks should cover high-risk copied values and representative journeys, while review owns prose meaning. diff --git a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md index 88c0c64266..0b6f1da543 100644 --- a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md +++ b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md @@ -51,7 +51,7 @@ Status: proposed [dsh-doc skill](../../../skills/dsh-doc/SKILL.md) 负责这些规则的首个可执行版本。`session-persistence-jsonl` README 对以已交付的追加、恢复与编码行为为证据,而不把其旧版正文当作权威。 - 每个撰写型包 README 都以可搜索 YAML 开头。Skill 风格的 `description` 与按机制推导的 `kind` 为必填字段。四种 kind 与四个技能模板一一对应:`package-group`(组地图)、`package-reference`(插件或服务包)、`package-library`(纯模块入口)与 `package-bundle`(`dsh.bundle.patch`)。对照文件路径、哈希与物理行对齐由支持自动合并的 sidecar 及其门禁负责,因此 README frontmatter 不包含 `i18n` 块。名称已由标题或包 manifest 归属,受众已由文档职责表达;在受治理的标签分类与搜索消费方证明其价值超过全文检索之前,不加入标签。 -- 撰写型页面先写三至五句的 `Summary`,再写带链接的 `Table of Contents`。由格式约束的 Agent Note、事故复盘、生成片段和机器文件保留其必需骨架。 +- 撰写型页面先写三至五句的 `Summary`,再写带链接的 `Table of Contents`。英文包 README 的 Summary 不超过 100 个按 `wc -w` 语义统计的词。它描述读者可见能力,而不是 Cordis 角色、注册项或内部组件;除非读者会在配置、命令或公开 API 中直接使用某个源码标识符,否则不得写入该标识符。由格式约束的 Agent Note、事故复盘、生成片段和机器文件保留其必需骨架。 - 每个实质章节在子章节、表格或代码之前先给出简短引导,页面则从基础用户用法逐步进入高级开发者与维护者细节。 - 英文技术正文采用受 ASD-STE100 启发但不宣称认证的清晰度评审:明确行动者与动作,稳定使用术语,使用直接动词,拆分指令与条件,并完整保留情态、例外、时序与数值。指令 20 词和描述 25 词的限制仅作评审提示。准确性高于句长。 - 包约定留在代码旁。跨包材料有计划地向 `docs/learn/overview/`、`docs/learn/cordis/`、`docs/learn/practices/`、`docs/user/`、`docs/developer/`、`docs/developer/discussion/`、`docs/scratch/` 和平行的 `docs/subsystems/` 层级迁移。 @@ -87,11 +87,11 @@ Status: proposed 1. 创建并验证 `dsh-doc`,再把一组 package README 对改写为行对齐、带元数据的原型,同时不改变运行时事实。 2. 用新人、用户、开发者和 agent 任务评审渲染后的原型;先修订 skill,再在其他位置强制执行该格式。 -3. 添加聚焦的元数据、章节顺序、行对齐、链接解析和配对 fixture。在每个合并与恢复消费方都有替代支持前,保留伴随文件。 +3. 添加聚焦的元数据、Summary 长度、章节顺序、行对齐、链接解析和配对 fixture。迁移所有违反已接受入口上限的既有包 Summary;在每个合并与恢复消费方都有替代支持前,保留伴随文件。 4. 把已接受的常驻规则提取到一份规范质量参考,将 `docs/AGENTS.md` 精简到目标以下,并且一次只组织一个内聚的 `docs/` 主题,同时原子地修复链接与导航。 5. 在 `config-catalog.md` 和 `docs/subsystems/core.md` 上制作生成参考入口层与细节层分离的原型;只有实测查询成本下降且没有丢失事实或造成路由扰动,才把确认后的模式应用到其他位置。 -该顺序使每项变更都能独立评审。前三个切片在不重写语料的情况下改进标准与正确性;生成文档原型则在更广的信息架构变更前提供证据。 +该顺序使每项变更都能独立评审。前三个切片改进标准与包入口,而不改变更广的信息架构;生成文档原型则在更广的结构变更前提供证据。 切片 1–3 已按此形式交付:`dsh-doc` 成为合并后的标准(`dsh-doc-standards` 与 `dsh-doc-site-sync` 已并入其中,站点工作流携带修正后的侧边栏值),`session-persistence-jsonl` README 对是参考示例,`pnpm run test:docs` 强制执行元数据、配对与快速文档检查。切片 4–5 仍待完成。 @@ -107,7 +107,7 @@ Status: proposed **把可读性分数作为质量门禁。**不予采纳,因为公式会惩罚精确技术术语,却无法发现错误所有权、遗漏失败行为、陈旧命令或破损的读者路径。 -**立即重写或拆分全部语料。**不予采纳,因为现有系统在机制上健康,许多长参考也确实应保持穷尽。原型应先证明检索有所改善,再扩散路由和翻译扰动。 +**立即重写或拆分全部文档语料。**不予采纳,因为现有系统在机制上健康,许多长参考也确实应保持穷尽。范围受限的包 Summary 迁移不会改变路由或穷尽式参考内容;更大的结构变更仍需实测证据。 **保留现有门禁,让评审负责友好程度。**不予采纳,因为陈旧工作流值和预算策略不一致说明,仅凭评审无法保留复制的语义事实,而现有门禁也不询问读者是否能完成任务。 @@ -116,6 +116,7 @@ Status: proposed - 一份规范质量参考按文档职责定义简短、直观、友好、准确和便于 agent 阅读的文档。 - `.agents/skills/dsh-doc` 通过验证,并直接链接其元数据、结构或层级及评审或原型参考,而不在 `SKILL.md` 中复制这些参考的详细规则。 - `session-persistence-jsonl` README 对展示可搜索 YAML、Summary、Table of Contents、从用户到开发者的渐进结构、Further Exploration、结尾 Dev Note、结构一致性和精确行数相等,同时保留已验证的包约定。 +- 每个英文包 README Summary 都不超过 100 个按 `wc -w` 语义统计的词;聚焦门禁报告实测词数,并引导失败项阅读 `dsh-doc` 与所选 kind 模板。 - `docs/AGENTS.md` 链接该参考,仍足以充当常驻指令,并低于其目标且至少保留 5% 余量。 - 根级用户路径、Web 快速开始、第一个插件教程、贡献者设置和架构概览各自给出一个可观察结果与验证归属者,同时不复制实现细节。 - 预算 manifest 同时记录目标与临时上限,其检查会报告或拒绝违反余量或棘轮规则的状态。 @@ -128,7 +129,7 @@ Status: proposed ## 风险 - 元数据可能沦为样板;因此包 README 检查只允许具有现行检索、模板选择或双语一致性消费方的字段。 -- 硬性句长限制可能割裂说明,或把条件与后果分开。受控英语的词数限制仅作评审提示,精确约定优先于句长。 +- 硬性句长限制可能割裂说明,或把条件与后果分开。受控英语的句长仅作评审提示;单独的 100 词包 Summary 上限只约束入口段落,精确约定仍保留在其归属章节。 - 精确行对齐可能迫使译者写出不自然的正文;评审必须保护含义,并可同时修订两侧,而不是削弱其中一侧。 - 拆分生成参考可能增加路由与链接维护;原型必须保留别名并衡量取舍。 - 语义检查可能膨胀成阻塞正当变更的仓库拓扑扫描器;检查应覆盖高风险复制值和代表性路径,而正文含义仍由评审负责。 diff --git a/.agents/skills/dsh-doc/SKILL.md b/.agents/skills/dsh-doc/SKILL.md index 87e6e6b3f1..96e2b60a8b 100644 --- a/.agents/skills/dsh-doc/SKILL.md +++ b/.agents/skills/dsh-doc/SKILL.md @@ -61,7 +61,7 @@ Open the template before writing and follow its skeleton and rules; it states wh These rules decide what a section may say. They apply to every authored human-facing page, and to package READMEs with particular force. -- **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does. +- **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. In a package Summary, “what it is” means only its reader-visible capability, not its Cordis role, registrations, or internal components. Omit source identifiers unless the reader directly uses them in configuration, a command, or a public API. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does. - **Developer sections explain, never enumerate.** Folded implementation content covers the overall design concept, architecture, and hand-waving dataflow — enough to understand how the package works — and links code for exact detail. No full API catalogs, exhaustive column lists, event-payload enumerations, or JSDoc restatement inside the folds. - **Dev Note is the only slop zone.** Partial ideas, scratches, undecided directions, measured artifacts, and working hypotheses live only in the final Dev Note, marked explicitly non-authoritative. Every other section is polished, current-state prose. - **Current state only.** No compatibility shims, migration talk, or history ("previously", "now", "no longer", renamed) outside the Dev Note; the codebase as it is today is the only subject. @@ -118,7 +118,7 @@ Validate the affected format, not merely Markdown syntax. A strong promise needs - Bilingual pages: verify structure, exact line count, terminology, link parity, and the sidecar record. - Tutorials: exercise the documented entry path or name an explicit manual verification owner. - Generated references: run the deterministic freshness check and report retrieval-size measures. -- Package READMEs: run model-experience and limitation checks, then package-focused tests when behavior claims changed; re-run every command the README instructs before merging a claim about it. +- Package READMEs: run the Summary gate, which limits each English Summary to 100 `wc -w`-style words and directs failures back to this skill and the kind template; run model-experience and limitation checks, then package-focused tests when behavior claims changed; re-run every command the README instructs before merging a claim about it. - Skills: run the repository's skill-invocation metadata check. Run `pnpm run test:docs` for the quick comprehensive documentation checks (pairing, wrap, links, README gates, budgets, skill metadata, Agent Note gates) before the full `pnpm run doc-sync`. diff --git a/.agents/skills/dsh-doc/references/review.md b/.agents/skills/dsh-doc/references/review.md index 94b24fb770..b1014c04f9 100644 --- a/.agents/skills/dsh-doc/references/review.md +++ b/.agents/skills/dsh-doc/references/review.md @@ -32,7 +32,7 @@ Retain a statement only when it helps the target reader act, reason, or avoid mi Require the following without forcing one universal internal heading set: - searchable YAML metadata with a precise `description` and the mechanically derived `kind` (`package-group`, `package-reference`, `package-library`, or `package-bundle`); -- a three-to-five-sentence Summary that says what the subject DOES for its user or agent reader, with a linked Table of Contents; +- a three-to-five-sentence English Summary of at most 100 `wc -w`-style words that says what the subject DOES for its user or agent reader, with a linked Table of Contents; - controlled English with explicit actors, stable terms, direct verbs, separated instructions and conditions, and unchanged modality; - when to choose or avoid the package; - a smallest safe configuration or usage path when one exists — for a bundle, the verified `dsh plugin` install path; for a library, the consumer entry point; never profile-install guidance for a shape that does not take it; @@ -42,7 +42,7 @@ Require the following without forcing one universal internal heading set: - newcomer-facing Further Exploration where adjacent docs materially help; - a final non-authoritative Dev Note as the only home for partial ideas, scratches, and undecided directions. -Do not restate JSDoc or generated catalogs. Link the owner and explain only the decision or relationship needed locally. Reject any user-facing section that narrates internals (function subjects, event streams, data flow) and any fold that enumerates APIs instead of explaining the concept. +Do not restate JSDoc or generated catalogs. Link the owner and explain only the decision or relationship needed locally. A package Summary describes reader-visible capability rather than Cordis roles, registrations, or internal components, and it omits source identifiers unless readers directly use them in configuration, commands, or a public API. Reject any user-facing section that narrates internals (function subjects, event streams, data flow) and any fold that enumerates APIs instead of explaining the concept. ## Reference example diff --git a/.agents/skills/dsh-doc/references/structure-hierarchy.md b/.agents/skills/dsh-doc/references/structure-hierarchy.md index 0f4935f9fa..6586c815a5 100644 --- a/.agents/skills/dsh-doc/references/structure-hierarchy.md +++ b/.agents/skills/dsh-doc/references/structure-hierarchy.md @@ -21,7 +21,7 @@ Use this order for authored human-facing pages when the format owner permits it. 1. YAML metadata. 2. H1 title. 3. Language switcher for a bilingual page. -4. `## Summary`: three to five explanatory sentences stating what the subject is, why a reader would care, the main operating model, and the most important boundary. +4. `## Summary`: three to five explanatory sentences stating what the reader can do or observe, why a reader would care, the main operating model, and the most important boundary. English package README Summaries stay within the gate-owned 100-word limit. 5. `## Table of Contents`: links to the page's H2 sections; keep it navigational rather than descriptive. 6. Stable content, ordered from user-facing use to developer-facing design and operational detail. 7. Optional `## Further Exploration` for newcomer-oriented links to adjacent subjects. diff --git a/.agents/skills/dsh-doc/references/style.md b/.agents/skills/dsh-doc/references/style.md index 9a974fda0d..f05f06ef49 100644 --- a/.agents/skills/dsh-doc/references/style.md +++ b/.agents/skills/dsh-doc/references/style.md @@ -15,7 +15,7 @@ Page-level style preferences that make DSH pages scannable and difficult to misr ## Short summary -Open every authored page with a short `Summary`: three to five sentences in one paragraph stating what the subject is, why the reader cares, the operating model, and the most important boundary. The Table of Contents and the sections carry the detail; placement and section order live in [structure-hierarchy.md](structure-hierarchy.md). +Open every authored page with a short `Summary`: three to five sentences in one paragraph stating what the reader can do or observe, why the reader cares, the operating model, and the most important boundary. The Table of Contents and the sections carry the detail; placement and section order live in [structure-hierarchy.md](structure-hierarchy.md). An English package README Summary is additionally limited to 100 `wc -w`-style words by `verify-package-readme-summaries`. ## Controlled technical English diff --git a/.agents/skills/dsh-doc/templates/package-bundle.md b/.agents/skills/dsh-doc/templates/package-bundle.md index 634d6347b0..59db5b0e30 100644 --- a/.agents/skills/dsh-doc/templates/package-bundle.md +++ b/.agents/skills/dsh-doc/templates/package-bundle.md @@ -22,7 +22,7 @@ English | [中文](README.zh.md) ## Summary -Three to five sentences: what a profile gains from this layer, which profiles already include it, how a user adds or removes it, and the main boundary. +Three to five sentences and at most 100 `wc -w`-style words: what a profile gains from this layer, which profiles already include it, how a user adds or removes it, and the main boundary. Apply the [Summary voice rules](../SKILL.md#voice-rules). ## Table of Contents diff --git a/.agents/skills/dsh-doc/templates/package-group.md b/.agents/skills/dsh-doc/templates/package-group.md index 7a9549de76..2e1fe14139 100644 --- a/.agents/skills/dsh-doc/templates/package-group.md +++ b/.agents/skills/dsh-doc/templates/package-group.md @@ -20,7 +20,7 @@ English | [中文](README.zh.md) ## Summary -Three to five sentences: what the family provides, what a reader can DO with it, which package owns which half, and the main boundary. +Three to five sentences and at most 100 `wc -w`-style words: what the family provides, what a reader can DO with it, which package owns which half, and the main boundary. Apply the [Summary voice rules](../SKILL.md#voice-rules). ## Table of Contents diff --git a/.agents/skills/dsh-doc/templates/package-library.md b/.agents/skills/dsh-doc/templates/package-library.md index fcd37f17b0..f576634f9c 100644 --- a/.agents/skills/dsh-doc/templates/package-library.md +++ b/.agents/skills/dsh-doc/templates/package-library.md @@ -22,7 +22,7 @@ English | [中文](README.zh.md) ## Summary -Three to five sentences: what a caller can DO with the library, who consumes it, the smallest entry point, and the main boundary. +Three to five sentences and at most 100 `wc -w`-style words: what a caller can DO with the library, who consumes it, the smallest entry point, and the main boundary. Apply the [Summary voice rules](../SKILL.md#voice-rules). ## Table of Contents diff --git a/.agents/skills/dsh-doc/templates/package-reference.md b/.agents/skills/dsh-doc/templates/package-reference.md index 5fda732779..499966301c 100644 --- a/.agents/skills/dsh-doc/templates/package-reference.md +++ b/.agents/skills/dsh-doc/templates/package-reference.md @@ -20,7 +20,7 @@ English | [中文](README.zh.md) ## Summary -Three to five sentences on what a user or agent can DO with the package: outcomes, when to choose it, main cost, most important boundary. Never its role, type, or internal identity. +Three to five sentences and at most 100 `wc -w`-style words on what a user or agent can DO with the package: outcomes, when to choose it, main cost, most important boundary. Apply the [Summary voice rules](../SKILL.md#voice-rules); never describe its role, type, or internal identity. ## Table of Contents diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 262851e4e2..c9793f14b3 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -218,9 +218,7 @@ export function retainIssueReferences(references, issues) { export function validateIssue(issue) { const errors = [] const status = issue.status - const invalidLabels = issue.labels.filter( - (label) => label.startsWith('kind/') || LEGACY_LABELS.has(label), - ) + const invalidLabels = issue.labels.filter(isInvalidIssueLabel) if (invalidLabels.length > 0) { errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`) @@ -245,6 +243,10 @@ export function validateIssue(issue) { return errors } +function isInvalidIssueLabel(label) { + return label.startsWith('kind/') || LEGACY_LABELS.has(label) +} + /** * Validate PR metadata and its referenced Issues. * @param {{authorType: string, labels: string[], references: ReturnType, issues: Map}} input PR snapshot. @@ -312,8 +314,9 @@ function projectToken() { } async function api(path, options = {}) { + const { allow404 = false, ...requestOptions } = options const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, { - ...options, + ...requestOptions, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token()}`, @@ -322,10 +325,10 @@ async function api(path, options = {}) { ...options.headers, }, }) - if (options.allow404 && response.status === 404) return null + if (allow404 && response.status === 404) return null if (!response.ok) { const body = await response.text() - throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status} ${body}`) + throw new Error(`${requestOptions.method ?? 'GET'} ${path}: ${response.status} ${body}`) } if (response.status === 204) return null return response.json() @@ -573,6 +576,25 @@ async function setStatus(number, status) { await updateStatus(await ensureProjectItem(number), status) } +/** + * Remove pull-request kinds and retired aliases from one Issue snapshot. + * @param {{number: number, labels: string[]}} issue Issue snapshot. + * @returns {Promise} Snapshot containing only labels that remain on the Issue. + */ +export async function repairIssueLabels(issue) { + const invalidLabels = issue.labels.filter(isInvalidIssueLabel) + for (const label of invalidLabels) { + await api( + `/repos/${config.organization}/${config.repository}/issues/${issue.number}/labels/${encodeURIComponent(label)}`, + { method: 'DELETE', allow404: true }, + ) + } + return { + ...issue, + labels: issue.labels.filter((label) => !isInvalidIssueLabel(label)), + } +} + async function upsertAudit(number, errors) { const comments = await api( `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, @@ -605,10 +627,18 @@ async function upsertAudit(number, errors) { } } -async function auditIssue(number, extraErrors = [], status = undefined) { +/** + * Repair deterministic Issue metadata violations and publish the remaining audit result. + * @param {number} number Same-repository Issue number. + * @param {string[]} extraErrors Errors supplied by the triggering lifecycle operation. + * @param {string|null|undefined} status Optional known Project status. + * @returns {Promise} Violations that remain after repair. + */ +export async function auditIssue(number, extraErrors = [], status = undefined) { const issue = await issueSnapshot(number, status) if (!issue) return [] - const errors = [...extraErrors, ...validateIssue(issue)] + const repairedIssue = await repairIssueLabels(issue) + const errors = [...extraErrors, ...validateIssue(repairedIssue)] await upsertAudit(number, errors) return errors } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index b39ab0491e..7ee2a85dc2 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -3,12 +3,14 @@ import { readFileSync, readdirSync } from 'node:fs' import test from 'node:test' import { + auditIssue, initializeIssueStartDate, initializePullRequestStartDates, issueSnapshot, nextResolvingIssueStatus, parseReferences, projectDate, + repairIssueLabels, retainIssueReferences, resolvingIssueStatusCommand, requiresPullRequestPolicy, @@ -248,6 +250,101 @@ test('reserves PR kind and legacy labels for pull requests', () => { assert.deepEqual(validateIssue({ ...legalIssue, labels: ['area/web', 'source/member'] }), []) }) +test('removes reserved labels from Issues before validation', async (t) => { + const previousToken = process.env.GH_TOKEN + process.env.GH_TOKEN = 'test-token' + t.after(() => { + if (previousToken === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = previousToken + }) + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, options) => { + requests.push({ url, method: options.method }) + assert.equal(options.headers.Authorization, 'Bearer test-token') + if (url.endsWith('/labels/bug-fix')) { + return Response.json({ message: 'Label does not exist' }, { status: 404 }) + } + return Response.json([]) + }) + + const issue = { + ...legalIssue, + number: 42, + labels: ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member'], + } + const repaired = await repairIssueLabels(issue) + + assert.deepEqual(repaired.labels, ['area/web', 'source/member']) + assert.deepEqual(issue.labels, ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member']) + assert.deepEqual(validateIssue(repaired), []) + assert.deepEqual(requests, [ + { + url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix', + method: 'DELETE', + }, + { + url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/bug-fix', + method: 'DELETE', + }, + ]) +}) + +test('deletes a stale audit comment after repairing its only violation', async (t) => { + const previousToken = process.env.GH_TOKEN + process.env.GH_TOKEN = 'test-token' + t.after(() => { + if (previousToken === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = previousToken + }) + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, options) => { + requests.push({ url, method: options.method ?? 'GET' }) + if (url.endsWith('/issues/42')) { + return Response.json({ + node_id: 'issue-id', + labels: [{ name: 'area/web' }, { name: 'kind/bug-fix' }], + type: { name: 'Bug' }, + state: 'open', + state_reason: null, + }) + } + if (url.endsWith('/graphql')) return Response.json({ data: projectGraphqlData() }) + if (url.endsWith('/labels/kind%2Fbug-fix')) return Response.json([{ name: 'area/web' }]) + if (url.endsWith('/issues/42/comments?per_page=100')) { + return Response.json([ + { + id: 99, + user: { type: 'Bot' }, + body: '\nold audit', + }, + ]) + } + if (url.endsWith('/issues/comments/99')) return new Response(null, { status: 204 }) + return Response.json({ message: 'unexpected request' }, { status: 500 }) + }) + + assert.deepEqual(await auditIssue(42), []) + assert.deepEqual( + requests.map(({ url, method }) => ({ path: new URL(url).pathname + new URL(url).search, method })), + [ + { path: '/repos/deepseek-harness/deepseek-harness/issues/42', method: 'GET' }, + { path: '/graphql', method: 'POST' }, + { + path: '/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix', + method: 'DELETE', + }, + { + path: '/repos/deepseek-harness/deepseek-harness/issues/42/comments?per_page=100', + method: 'GET', + }, + { + path: '/repos/deepseek-harness/deepseek-harness/issues/comments/99', + method: 'DELETE', + }, + ], + ) +}) + test('keeps terminal Status aligned with the native close reason', () => { assert.deepEqual( validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }), diff --git a/.github/review-ownership/CODEOWNERS b/.github/review-ownership/CODEOWNERS index 5b41121d64..eccc7e6844 100644 --- a/.github/review-ownership/CODEOWNERS +++ b/.github/review-ownership/CODEOWNERS @@ -6,7 +6,6 @@ /native/ @mektpoy /patches/ @mektpoy /python/ @LegGasai -/scripts/ @turtle1999 /vendor/ @turtle1999 /website/ @LegGasai /packages/acp/ @mektpoy @@ -18,7 +17,7 @@ /packages/code-runtime/ @Chinesezjc /packages/compaction/ @imccyu /packages/context/ @turtle1999 -/packages/core/ @tianyicui @turtle1999 @mektpoy +/packages/core/ @turtle1999 @mektpoy /packages/credentials/ @mektpoy /packages/e2b/ @mektpoy /packages/experimental/ @mektpoy @@ -41,7 +40,7 @@ /packages/sandbox/ @mektpoy /packages/schedule/ @imccyu /packages/sdk/ @mektpoy -/packages/session/ @tianyicui @turtle1999 @mektpoy +/packages/session/ @turtle1999 @mektpoy /packages/session-query/ @mektpoy /packages/settings/ @mektpoy /packages/shell/ @mektpoy diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md index 95dd52595b..f6f232c895 100644 --- a/.github/review-ownership/README.md +++ b/.github/review-ownership/README.md @@ -1,15 +1,13 @@ # Automated review requests -English | [中文](README.zh.md) - ## Summary -The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It prints the complete changed non-test file list, matches those files to owners, and then requests the missing reviewers. 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) 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. ## Table of Contents - [Routing](#routing) -- [Test exclusion](#test-exclusion) +- [Review exclusions](#review-exclusions) - [Security](#security) - [Verification](#verification) - [Dev Note](#dev-note) @@ -18,20 +16,30 @@ The [`request-review` workflow](../workflows/request-review.yml) reads the CODEO ## Routing -Non-draft pull requests run the workflow when opened, synchronized, reopened, or marked ready for review. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API. +Pull requests run the workflow when opened, synchronized, reopened, marked ready for review, or converted to a draft. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API. -The ownership map accepts explicit absolute directory patterns and individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints `Changed code files`, `Excluded test files`, `Owners by changed file`, and `Reviewers to request` before it sends the review request. Unmatched files remain visible in the log. The pull-request author and users who are already requested are omitted. +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; 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. - + -## Test exclusion +## Review exclusions Review routing excludes the repository's unit, end-to-end, expected-output, snapshot, benchmark, performance, stress, corpus, native, and Python test conventions. This includes `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, and `stress-tests` directories; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; recognized test filename suffixes; and Python `test_*.py` or `*_test.py` files. Test infrastructure that can alter how evidence is produced remains reviewable, including `vitest*.config.ts` and gate implementations under `scripts`. A production file named `test.ts`, `spec.ts`, or `snapshot.ts` is not excluded solely by that name. +Files ending in `.md` or `.yaml`, with case-insensitive extension matching, are documentation and never contribute owners. A `.yml` file remains reviewable unless another exclusion applies. + +For a modified file with a supported source extension, the scanner compares the pre-change and post-change text after removing parsed comments. It excludes the file only when GitHub supplies a patch whose counted additions and deletions prove that the patch is complete and the remaining code is identical. The parser recognizes C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for their declared extensions. Renames, unsupported languages, missing or partial patches, and uncertain comment forms remain reviewable. + ## Security @@ -44,7 +52,7 @@ Ownership changes take effect only after they merge into the default branch. Thi ## Verification -Run `pnpm run test:request-review` for ownership parsing, test classification, pagination, logging order, 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. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI. diff --git a/.github/review-ownership/README.zh.md b/.github/review-ownership/README.zh.md deleted file mode 100644 index a20a5f4ae4..0000000000 --- a/.github/review-ownership/README.zh.md +++ /dev/null @@ -1,53 +0,0 @@ -# 自动请求代码评审 - -[English](README.md) | 中文 - -## 概要 - -[`request-review` workflow](../workflows/request-review.yml) 从受信任的默认分支读取兼容 CODEOWNERS 格式的[所有权映射](CODEOWNERS)。它先打印完整的非测试变更文件列表,再将这些文件与 owner 匹配,最后请求尚未加入的评审者。所有权映射不在 GitHub 原生 CODEOWNERS 路径中,因此 GitHub 不会直接应用它。 - -## 目录 - -- [路由](#routing) -- [排除测试](#test-exclusion) -- [安全性](#security) -- [验证](#verification) -- [开发说明](#dev-note) - - - -## 路由 - -非草稿 PR 在创建、同步、重新打开或标记为可评审时运行该 workflow。扫描器获取完整的 PR 文件列表,分别检查重命名前后的路径;如果只能取得部分列表,则停止执行,不发出评审请求。GitHub 对此 API 最多公开 3,000 个文件。 - -所有权映射只接受显式绝对目录模式和 GitHub 个人用户。通配符、隐藏目录模式、团队,以及重复的模式或 owner 都会被拒绝。匹配遵循 CODEOWNERS 的最后一条匹配规则。扫描器先打印 `Changed code files`、`Excluded test files`、`Owners by changed file` 和 `Reviewers to request`,再发送评审请求。未匹配的文件仍显示在日志中。PR 作者和已经收到评审请求的用户会被排除。 - -策略测试会统计已匹配目录下的非测试跟踪文件行数,并要求 `@turtle1999` 拥有的有效代码库不超过三分之一。 - - - -## 排除测试 - -评审路由会排除仓库中的单元测试、端到端测试、预期输出、快照、基准测试、性能测试、压力测试、语料测试、原生测试和 Python 测试约定。其中包括 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 和 `stress-tests` 目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,可识别的测试文件名后缀,以及 Python 的 `test_*.py` 或 `*_test.py` 文件。 - -能够改变证据生成方式的测试基础设施仍需评审,包括 `vitest*.config.ts` 和 `scripts` 下的门禁实现。生产文件不会仅因名称为 `test.ts`、`spec.ts` 或 `snapshot.ts` 而被排除。 - - - -## 安全性 - -具备写权限的 `pull_request_target` job 只检出仓库默认分支。它不会检出或执行 PR 代码,也不使用仓库 secret。PR 文件名仅作为 API 数据处理,并在日志中转义。 - -所有权变更只有合并到默认分支后才会生效。这可以防止不受信任的 PR 为自身的 workflow 运行修改路由程序或 owner 分配。 - - - -## 验证 - -运行 `pnpm run test:request-review` 可检查所有权解析、测试分类、分页、日志顺序、评审者过滤和 API 行为。[Workflow 测试](../../scripts/ci-workflow.spec.ts)固定受信任检出、权限、事件和命令。仓库门禁图会在 CI 中运行这两类检查。 - - - -## 开发说明 - -[评审路由决策](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md)记录了安全模型、测试排除规则和备选方案。 diff --git a/.github/review-ownership/request-review.mjs b/.github/review-ownership/request-review.mjs index 5d71108abe..c2d1ee7f43 100644 --- a/.github/review-ownership/request-review.mjs +++ b/.github/review-ownership/request-review.mjs @@ -5,11 +5,26 @@ import process from 'node:process' 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']) const TEST_FILE_MARKER = /\.(?:bench|corpus|e2e|perf|snapshot|spec|stress|test)\.[^./]+$/u const PYTHON_TEST_FILE = /^(?:test_.+|.+_tests?)\.py$/u +const DOCUMENTATION_FILE = /\.(?:md|yaml)$/iu +const C_STYLE_EXTENSIONS = new Set([ + 'c', 'cc', 'cjs', 'cpp', 'cts', 'cxx', 'go', 'h', 'hpp', 'java', 'js', 'jsx', + 'kt', 'kts', 'less', 'mjs', 'mts', 'rs', 'scss', 'swift', 'ts', 'tsx', +]) +const BLOCK_COMMENT_EXTENSIONS = new Set(['css']) +const HASH_COMMENT_EXTENSIONS = new Set(['bash', 'ps1', 'py', 'pyi', 'r', 'rb', 'sh', 'toml', 'yml', 'zsh']) +const HTML_COMMENT_EXTENSIONS = new Set(['htm', 'html']) /** * Parse the explicit directory subset accepted from the review ownership file. @@ -30,6 +45,9 @@ export function parseOwnership(source) { if (pattern.startsWith('/.')) throw new Error(`${location}: hidden-directory patterns are not allowed`) if (patterns.has(pattern)) throw new Error(`${location}: duplicate pattern ${JSON.stringify(pattern)}`) if (owners.length === 0) throw new Error(`${location}: expected at least one owner`) + if (owners.length > MAX_OWNERS_PER_RULE) { + throw new Error(`${location}: expected at most ${MAX_OWNERS_PER_RULE} owners`) + } const normalizedOwners = [] const seenOwners = new Set() for (const owner of owners) { @@ -83,50 +101,229 @@ export function isTestPath(value) { } /** - * Expand changed-file records into reviewable and excluded repository paths. + * Decide whether a repository path is documentation excluded from review routing. + * @param {string} value Repository-relative path. + * @returns {boolean} Whether the path has an excluded documentation extension. + */ +export function isDocumentationPath(value) { + return DOCUMENTATION_FILE.test(normalizeRepositoryPath(value)) +} + +/** + * Decide whether a complete modified-file patch changes comments only. + * @param {unknown} value GitHub changed-file record. + * @returns {boolean} Whether supported comment parsing removes every changed token. + */ +export function isCommentOnlyChange(value) { + if (!isRecord(value) || value.status !== 'modified' || typeof value.filename !== 'string' + || typeof value.patch !== 'string' || !Number.isSafeInteger(value.additions) + || value.additions < 0 || !Number.isSafeInteger(value.deletions) || value.deletions < 0) return false + const syntax = commentSyntax(value.filename) + if (syntax === undefined) return false + if (value.filename.toLowerCase().endsWith('.rs') && /\b(?:br|r)#{0,255}"/u.test(value.patch)) return false + const hunks = parsePatchHunks(value.patch) + if (hunks === undefined || hunks.additions !== value.additions || hunks.deletions !== value.deletions) { + return false + } + return hunks.values.every(({ before, after }) => + normalizedCode(before, syntax) === normalizedCode(after, syntax)) +} + +function commentSyntax(filename) { + const normalized = normalizeRepositoryPath(filename) + const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase() + const extension = basename.includes('.') ? basename.slice(basename.lastIndexOf('.') + 1) : '' + const line = [] + const block = [] + if (C_STYLE_EXTENSIONS.has(extension)) { + line.push('//') + block.push(['/*', '*/']) + } + if (BLOCK_COMMENT_EXTENSIONS.has(extension)) block.push(['/*', '*/']) + if (HASH_COMMENT_EXTENSIONS.has(extension) || basename === 'dockerfile' || basename.startsWith('dockerfile.') + || basename === 'makefile' || basename.startsWith('makefile.')) line.push('#') + if (extension === 'sql') { + line.push('--') + block.push(['/*', '*/']) + } + if (HTML_COMMENT_EXTENSIONS.has(extension)) block.push(['']) + return line.length === 0 && block.length === 0 ? undefined : { line, block } +} + +function parsePatchHunks(patch) { + const values = [] + let current + let additions = 0 + let deletions = 0 + for (const line of patch.split('\n')) { + if (line.startsWith('@@')) { + current = { before: [], after: [] } + values.push(current) + continue + } + if (current === undefined || line.startsWith('\\ No newline at end of file')) continue + const prefix = line[0] + const content = line.slice(1) + if (prefix === ' ') { + current.before.push(content) + current.after.push(content) + } else if (prefix === '-') { + current.before.push(content) + deletions++ + } else if (prefix === '+') { + current.after.push(content) + additions++ + } + } + return values.length === 0 ? undefined : { values, additions, deletions } +} + +function normalizedCode(lines, syntax) { + return stripComments(lines.join('\n'), syntax) + .split('\n') + .map(line => line.trimEnd()) + .filter(line => line.trim().length > 0) + .join('\n') +} + +function stripComments(source, syntax) { + let result = '' + let quote + let blockEnd + for (let index = 0; index < source.length;) { + if (blockEnd !== undefined) { + if (source.startsWith(blockEnd, index)) { + index += blockEnd.length + blockEnd = undefined + } else { + index++ + } + continue + } + const character = source[index] + if (quote !== undefined) { + result += character + index++ + if (character === '\\' && index < source.length) { + result += source[index] + index++ + } else if (character === quote) { + quote = undefined + } + continue + } + if (character === '\'' || character === '"' || character === '`') { + quote = character + result += character + index++ + continue + } + const block = syntax.block.find(([start]) => source.startsWith(start, index)) + if (block !== undefined) { + index += block[0].length + blockEnd = block[1] + continue + } + const line = syntax.line.find(marker => source.startsWith(marker, index)) + const lineStart = index === 0 || source[index - 1] === '\n' + const hashStartsComment = line !== '#' || lineStart || /\s/u.test(source[index - 1] ?? '') + if (line !== undefined && hashStartsComment && !(line === '#' && lineStart && source[index + 1] === '!')) { + const newline = source.indexOf('\n', index + line.length) + if (newline === -1) break + result += '\n' + index = newline + 1 + continue + } + result += character + index++ + } + return result +} + +/** + * Expand changed-file records into reviewable, test, documentation, and comment-only paths. * @param {unknown[]} files Pull-request file records from GitHub. - * @returns {{changedCodeFiles: string[], excludedTestFiles: string[]}} Classified paths. + * @returns {{changedCodeFiles: string[], reviewableChanges: Array<{paths: string[], changedLines: number}>, excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths and their GitHub-reported changed-line counts. */ export function classifyChangedFiles(files) { const changedCodeFiles = new Set() + const reviewableChanges = [] const excludedTestFiles = new Set() + const excludedDocumentationFiles = new Set() + const excludedCommentOnlyFiles = new Set() for (const entry of files) { if (!isRecord(entry)) throw new Error('changed-file response contains a non-object entry') + const changedLines = changedLineCount(entry) const paths = [normalizeRepositoryPath(entry.filename)] + const commentOnly = isCommentOnlyChange(entry) if (entry.previous_filename !== undefined) { paths.unshift(normalizeRepositoryPath(entry.previous_filename)) } - for (const file of paths) { + const reviewablePaths = [] + for (const file of new Set(paths)) { if (isTestPath(file)) excludedTestFiles.add(file) - else changedCodeFiles.add(file) + else if (isDocumentationPath(file)) excludedDocumentationFiles.add(file) + else if (commentOnly) excludedCommentOnlyFiles.add(file) + else { + changedCodeFiles.add(file) + reviewablePaths.push(file) + } + } + if (reviewablePaths.length > 0) { + reviewableChanges.push({ paths: reviewablePaths.sort(), changedLines }) } } return { changedCodeFiles: [...changedCodeFiles].sort(), + reviewableChanges, excludedTestFiles: [...excludedTestFiles].sort(), + excludedDocumentationFiles: [...excludedDocumentationFiles].sort(), + excludedCommentOnlyFiles: [...excludedCommentOnlyFiles].sort(), } } +function changedLineCount(entry) { + for (const field of ['additions', 'deletions']) { + if (!Number.isSafeInteger(entry[field]) || entry[field] < 0) { + throw new Error(`changed-file ${field} must be a non-negative integer`) + } + } + const changedLines = entry.additions + entry.deletions + if (!Number.isSafeInteger(changedLines)) throw new Error('changed-file LOC exceeds the safe integer range') + return changedLines +} + /** - * Match changed paths to owners with CODEOWNERS last-match semantics. + * Match changed paths and rank owners by their reviewable changed LOC. * @param {Array<{prefix: string, owners: string[]}>} rules Ordered ownership rules. - * @param {string[]} changedCodeFiles Reviewable repository paths. - * @returns {{matches: Array<{file: string, owners: string[]}>, reviewers: string[]}} Routing plan. + * @param {Array<{paths: string[], changedLines: number}>} reviewableChanges Reviewable GitHub file records. + * @returns {{matches: Array<{file: string, changedLines: number, owners: string[]}>, reviewers: Array<{login: string, changedLines: number}>}} Routing plan. */ -export function planReviewers(rules, changedCodeFiles) { +export function planReviewers(rules, reviewableChanges) { const matches = [] const reviewers = new Map() - for (const file of changedCodeFiles) { - let owners = [] - for (const rule of rules) { - if (file.startsWith(rule.prefix)) owners = rule.owners + for (const change of reviewableChanges) { + const changeOwners = new Map() + for (const file of change.paths) { + let owners = [] + for (const rule of rules) { + if (file.startsWith(rule.prefix)) owners = rule.owners + } + matches.push({ file, changedLines: change.changedLines, owners }) + for (const owner of owners) changeOwners.set(owner.toLowerCase(), owner.slice(1)) + } + for (const [key, login] of changeOwners) { + const changedLines = (reviewers.get(key)?.changedLines ?? 0) + change.changedLines + if (!Number.isSafeInteger(changedLines)) throw new Error(`changed LOC for @${login} exceeds the safe integer range`) + reviewers.set(key, { login, changedLines }) } - matches.push({ file, owners }) - for (const owner of owners) reviewers.set(owner.toLowerCase(), owner.slice(1)) } return { - matches, - reviewers: [...reviewers.values()].sort((left, right) => left.localeCompare(right, 'en')), + matches: matches.sort((left, right) => left.file.localeCompare(right.file, 'en')), + reviewers: [...reviewers.values()].sort((left, right) => { + if (left.changedLines !== right.changedLines) return left.changedLines < right.changedLines ? 1 : -1 + return left.login.localeCompare(right.login, 'en') + }), } } @@ -190,54 +387,227 @@ export async function listPullRequestFiles(api, repository, pullNumber, expected } /** - * Print changed paths, route owners, and request every missing eligible reviewer. + * 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. + * @param {string} repository Owner/name repository identifier. + * @param {number} pullNumber Pull-request number. + * @returns {Promise} Complete timeline event list within the supported limit. + */ +export async function listPullRequestTimeline(api, repository, pullNumber) { + const events = [] + for (let page = 1; ; page++) { + const response = await api(`/repos/${repository}/issues/${pullNumber}/timeline?per_page=${PAGE_SIZE}&page=${page}`) + if (!Array.isArray(response)) throw new Error('pull-request timeline response is not an array') + events.push(...response) + if (response.length < PAGE_SIZE) return events + if (events.length >= MAX_TIMELINE_EVENTS) { + throw new Error(`pull-request timeline exceeds ${MAX_TIMELINE_EVENTS} events`) + } + } +} + +/** Return current requested reviewers whose latest request came from this workflow identity. */ +function workflowRequestedReviewers(events, requestedReviewers) { + const requested = new Map(requestedReviewers.map(login => [login.toLowerCase(), login])) + const latestRequester = new Map() + for (const event of events) { + if (!isRecord(event) || event.event !== 'review_requested') continue + if (!isRecord(event.requested_reviewer) || typeof event.requested_reviewer.login !== 'string') continue + const key = event.requested_reviewer.login.toLowerCase() + if (!requested.has(key)) continue + if (!isRecord(event.review_requester) || typeof event.review_requester.login !== 'string') { + throw new Error('review-request timeline event has no requester login') + } + latestRequester.set(key, event.review_requester.login.toLowerCase()) + } + return [...requested] + .filter(([key]) => latestRequester.get(key) === WORKFLOW_REVIEW_REQUESTER) + .map(([, login]) => login) +} + +/** Extract and validate individual logins from GitHub's requested-reviewer response. */ +function requestedReviewerLogins(response) { + if (!isRecord(response) || !Array.isArray(response.users)) { + throw new Error('requested-reviewers response has no users array') + } + return response.users.map((user) => { + if (!isRecord(user) || typeof user.login !== 'string') { + throw new Error('requested-reviewers response contains an invalid user') + } + return user.login + }) +} + +/** + * Print changed paths, reconcile workflow-authored requests with current + * ownership, and cancel workflow-authored requests on drafts. * @param {{event: unknown, ownershipSource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, write?: (line: string) => void}} options Runtime inputs. - * @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], requestedReviewers: string[]}>} Applied routing result. + * @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[], requestedReviewers: string[], cancelledReviewers: string[]}>} Applied routing result. */ export async function requestReviews({ event, ownershipSource, api, write = line => process.stdout.write(`${line}\n`) }) { const pull = pullRequestFromEvent(event) write('This is by automated Angry Turtle Cyborg, not a human') - if (pull.draft) { - write('Draft pull request; reviewer routing is deferred until ready_for_review.') - return { changedCodeFiles: [], excludedTestFiles: [], requestedReviewers: [] } - } - const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount) - const classified = classifyChangedFiles(files) - const plan = planReviewers(parseOwnership(ownershipSource), classified.changedCodeFiles) + const { reviewableChanges, ...classified } = classifyChangedFiles(files) + const plan = planReviewers(parseOwnership(ownershipSource), reviewableChanges) writeList(write, 'Changed code files', classified.changedCodeFiles.map(file => JSON.stringify(file))) writeList(write, 'Excluded test files', classified.excludedTestFiles.map(file => JSON.stringify(file))) + writeList( + write, + 'Excluded documentation files', + classified.excludedDocumentationFiles.map(file => JSON.stringify(file)), + ) + writeList( + write, + 'Excluded comment-only files', + classified.excludedCommentOnlyFiles.map(file => JSON.stringify(file)), + ) writeList( write, 'Owners by changed file', - plan.matches.map(({ file, owners }) => `${JSON.stringify(file)}: ${owners.length ? owners.join(' ') : '(none)'}`), + plan.matches.map(({ file, changedLines, owners }) => + `${JSON.stringify(file)} (${changedLines} LOC): ${owners.length ? owners.join(' ') : '(none)'}`), + ) + writeList( + write, + 'Owner relevance by changed LOC', + plan.reviewers.map(({ login, changedLines }) => `@${login}: ${changedLines}`), ) - const candidates = plan.reviewers.filter(login => login.toLowerCase() !== pull.author.toLowerCase()) - if (candidates.length === 0) { - writeList(write, 'Reviewers to request', []) - return { ...classified, requestedReviewers: [] } - } - const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`) - if (!isRecord(existing) || !Array.isArray(existing.users)) { - throw new Error('requested-reviewers response has no users array') - } - const alreadyRequested = new Set(existing.users.map((user) => { - if (!isRecord(user) || typeof user.login !== 'string') { - throw new Error('requested-reviewers response contains an invalid user') - } - return user.login.toLowerCase() - })) - const reviewers = candidates.filter(login => !alreadyRequested.has(login.toLowerCase())) - writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`)) - if (reviewers.length === 0) return { ...classified, requestedReviewers: [] } + 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) + const reviewers = requestedReviewers.length === 0 + ? [] + : workflowRequestedReviewers( + await listPullRequestTimeline(api, pull.repository, pull.number), + requestedReviewers, + ) + writeList(write, 'Review requests to cancel', reviewers.map(login => `@${login}`)) + if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] } - await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { - method: 'POST', - body: { reviewers }, - }) - write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`) - return { ...classified, requestedReviewers: reviewers } + await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { + method: 'DELETE', + body: { reviewers }, + }) + const requestLabel = reviewers.length === 1 ? 'request' : 'requests' + write(`Cancelled review ${requestLabel} for ${reviewers.map(login => `@${login}`).join(' ')}.`) + 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 + ? [] + : workflowRequestedReviewers( + await listPullRequestTimeline(api, pull.repository, pull.number), + currentReviewers, + ) + const workflowReviewerKeys = new Set(workflowReviewers.map(login => login.toLowerCase())) + const manualReviewers = currentReviewers.filter(login => !workflowReviewerKeys.has(login.toLowerCase())) + let retainedCountedSlots = Math.max( + 0, + MAX_COUNTED_REQUESTED_REVIEWERS + - manualReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length, + ) + const retainedWorkflowReviewerKeys = new Set() + for (const { login } of candidates) { + const key = login.toLowerCase() + if (!workflowReviewerKeys.has(key)) continue + if (key === UNCOUNTED_REVIEWER) retainedWorkflowReviewerKeys.add(key) + else if (retainedCountedSlots > 0) { + retainedWorkflowReviewerKeys.add(key) + retainedCountedSlots-- + } + } + const reviewersToCancel = workflowReviewers.filter( + login => !retainedWorkflowReviewerKeys.has(login.toLowerCase()), + ) + const cancelledReviewerKeys = new Set(reviewersToCancel.map(login => login.toLowerCase())) + const remainingReviewers = currentReviewers.filter(login => !cancelledReviewerKeys.has(login.toLowerCase())) + const alreadyRequested = new Set(remainingReviewers.map(login => login.toLowerCase())) + const availableSlots = Math.max( + 0, + MAX_COUNTED_REQUESTED_REVIEWERS + - remainingReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length, + ) + writeList(write, 'Current individual review requests', currentReviewers.map(login => `@${login}`)) + write(`Available counted review request slots: ${availableSlots}.`) + const reviewers = candidates + .filter(({ login }) => !alreadyRequested.has(login.toLowerCase())) + .slice(0, availableSlots) + .map(({ login }) => login) + writeList(write, 'Review requests to cancel', reviewersToCancel.map(login => `@${login}`)) + writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`)) + if (reviewersToCancel.length > 0) { + await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { + method: 'DELETE', + body: { reviewers: reviewersToCancel }, + }) + const requestLabel = reviewersToCancel.length === 1 ? 'request' : 'requests' + write(`Cancelled review ${requestLabel} for ${reviewersToCancel.map(login => `@${login}`).join(' ')}.`) + } + + if (reviewers.length > 0) { + await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { + method: 'POST', + body: { reviewers }, + }) + write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`) + } + return { ...classified, requestedReviewers: reviewers, cancelledReviewers: reviewersToCancel } } function pullRequestFromEvent(event) { diff --git a/.github/review-ownership/request-review.test.mjs b/.github/review-ownership/request-review.test.mjs index 79a44d7fd5..fdb3d763a4 100644 --- a/.github/review-ownership/request-review.test.mjs +++ b/.github/review-ownership/request-review.test.mjs @@ -1,13 +1,18 @@ import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import test from 'node:test' import { + approvedReviewerLogins, classifyChangedFiles, createGitHubApi, + isCommentOnlyChange, + isDocumentationPath, isTestPath, listPullRequestFiles, + listPullRequestReviews, + listPullRequestTimeline, normalizeRepositoryPath, parseOwnership, planReviewers, @@ -29,37 +34,37 @@ const pullRequestEvent = ({ author = 'author', changedFiles = 1, draft = false } test('loads the repository ownership policy without test-only directory rules', () => { const rules = parseOwnership(ownershipSource) const ownersByPattern = new Map(rules.map(rule => [rule.pattern, rule.owners])) - assert.equal(rules.length, 58) + assert.equal(rules.length, 57) assert.equal(rules.some(rule => rule.pattern === '/benchmarks/'), false) + assert.equal(rules.some(rule => rule.pattern === '/scripts/'), false) assert.equal(rules.some(rule => rule.pattern === '/snapshots/'), false) assert.equal(rules.some(rule => rule.pattern === '/packages/test-support/'), false) assert.deepEqual(ownersByPattern.get('/apps/cli/'), ['@turtle1999']) assert.deepEqual(ownersByPattern.get('/docs/'), ['@turtle1999']) - assert.deepEqual(ownersByPattern.get('/packages/core/'), ['@tianyicui', '@turtle1999', '@mektpoy']) + assert.deepEqual(ownersByPattern.get('/packages/core/'), ['@turtle1999', '@mektpoy']) assert.deepEqual(ownersByPattern.get('/packages/llm/'), ['@LegGasai']) assert.deepEqual(ownersByPattern.get('/packages/preset/'), ['@LegGasai', '@turtle1999']) - assert.deepEqual(ownersByPattern.get('/packages/session/'), ['@tianyicui', '@turtle1999', '@mektpoy']) + assert.deepEqual(ownersByPattern.get('/packages/session/'), ['@turtle1999', '@mektpoy']) assert.deepEqual(ownersByPattern.get('/packages/subagent/'), ['@Dudu-0223']) assert.deepEqual(ownersByPattern.get('/packages/web/'), ['@imccyu']) assert.deepEqual(ownersByPattern.get('/python/'), ['@LegGasai']) assert.deepEqual(ownersByPattern.get('/website/'), ['@LegGasai']) - assert.deepEqual( - rules.filter(rule => rule.owners.includes('@tianyicui')).map(rule => rule.pattern), - ['/packages/core/', '/packages/session/'], - ) - for (const excludedOwner of ['@kermeanx', '@pkh-xht']) { + assert.equal(rules.every(rule => rule.owners.length <= 2), true) + for (const excludedOwner of ['@tianyicui', '@kermeanx', '@pkh-xht']) { assert.equal(rules.some(rule => rule.owners.some(owner => owner.toLowerCase() === excludedOwner)), false) } }) test('keeps turtle below one third of the eligible owned codebase', () => { const rules = parseOwnership(ownershipSource) - const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' }).split('\0').filter(Boolean) + const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' }) + .split('\0') + .filter(file => file && existsSync(file)) let ownedLines = 0 let turtleLines = 0 for (const file of trackedFiles) { - if (isTestPath(file)) continue - const owners = planReviewers(rules, [file]).matches[0]?.owners ?? [] + if (isTestPath(file) || isDocumentationPath(file)) continue + const owners = planReviewers(rules, [{ paths: [file], changedLines: 0 }]).matches[0]?.owners ?? [] if (owners.length === 0) continue const content = readFileSync(file) const lines = content.length === 0 @@ -82,6 +87,7 @@ test('rejects ownership forms the requester cannot apply safely', () => { ['/packages/*/ @owner\n', /explicit absolute directory/u], ['/packages/core/\n', /at least one owner/u], ['/packages/core/ @org/team\n', /individual GitHub users/u], + ['/packages/core/ @one @two @three\n', /at most 2 owners/u], ['/packages/core/ @owner @OWNER\n', /duplicate owner/u], ['/packages/core/ @owner\n/packages/core/ @other\n', /duplicate pattern/u], ]) { @@ -132,6 +138,76 @@ test('does not confuse production names with tests', () => { } }) +test('excludes Markdown and YAML documentation extensions', () => { + for (const file of [ + 'README.md', + 'docs/architecture.MD', + 'packages/subagent/subagent/guide.yaml', + 'profiles/example.YAML', + ]) { + assert.equal(isDocumentationPath(file), true, file) + } + for (const file of [ + '.github/workflows/request-review.yml', + 'packages/subagent/subagent/src/index.ts', + 'website/docs.ts', + ]) { + assert.equal(isDocumentationPath(file), false, file) + } +}) + +test('detects comment-only changes only from complete supported patches', () => { + for (const file of [ + { + filename: 'packages/core/agent/src/index.ts', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1,2 +1,2 @@\n-// old note\n+// new note\n const value = "https://example.com"', + }, + { + filename: 'python/sdk/src/client.py', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-value = 1 # old note\n+value = 1 # new note', + }, + { + filename: 'native/landlock-run/src/main.rs', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-let value = 1; /* old note */\n+let value = 1; /* new note */', + }, + ]) { + assert.equal(isCommentOnlyChange(file), true, file.filename) + } + + for (const file of [ + { + filename: 'packages/core/agent/src/index.ts', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-const value = 1 // note\n+const value = 2 // note', + }, + { + filename: 'packages/core/agent/src/index.ts', + status: 'modified', additions: 2, deletions: 1, + patch: '@@ -1 +1 @@\n-// old note\n+// new note', + }, + { + filename: 'packages/core/agent/src/data.json', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-{"value":1}\n+{"value":2}', + }, + { + filename: 'native/landlock-run/src/main.rs', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-let value = r#"https://old.example"#;\n+let value = r#"https://new.example"#;', + }, + { + filename: 'packages/core/agent/src/index.ts', + status: 'renamed', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-// old note\n+// new note', + }, + ]) { + assert.equal(isCommentOnlyChange(file), false, file.filename) + } +}) + test('normalizes separators and rejects paths that are not repository-relative', () => { assert.equal(normalizeRepositoryPath('./packages\\core\\agent\\src\\index.ts'), 'packages/core/agent/src/index.ts') for (const file of ['', '/absolute.ts', '../escape.ts', 'packages//empty.ts', 'packages/./same.ts']) { @@ -145,10 +221,20 @@ test('classifies both sides of a rename independently', () => { { filename: 'packages/core/agent/tests/moved.spec.ts', previous_filename: 'packages/core/agent/src/moved.ts', + additions: 3, + deletions: 2, }, { filename: 'packages/client/store/src/restored.ts', previous_filename: 'packages/client/store/tests/restored.spec.ts', + additions: 2, + deletions: 1, + }, + { filename: 'packages/core/agent/README.md', additions: 1, deletions: 0 }, + { + filename: 'packages/core/agent/src/commented.ts', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-// old note\n+// new note', }, ]), { @@ -156,27 +242,66 @@ test('classifies both sides of a rename independently', () => { 'packages/client/store/src/restored.ts', 'packages/core/agent/src/moved.ts', ], + reviewableChanges: [ + { paths: ['packages/core/agent/src/moved.ts'], changedLines: 5 }, + { paths: ['packages/client/store/src/restored.ts'], changedLines: 3 }, + ], excludedTestFiles: [ 'packages/client/store/tests/restored.spec.ts', 'packages/core/agent/tests/moved.spec.ts', ], + excludedDocumentationFiles: ['packages/core/agent/README.md'], + excludedCommentOnlyFiles: ['packages/core/agent/src/commented.ts'], + }, + ) +}) + +test('uses the last matching ownership rule and ranks owners by changed LOC', () => { + const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n') + assert.deepEqual( + planReviewers(rules, [ + { paths: ['AGENTS.md'], changedLines: 1 }, + { paths: ['packages/core/agent/src/index.ts'], changedLines: 8 }, + { paths: ['packages/fs/fs/src/index.ts'], changedLines: 3 }, + ]), + { + matches: [ + { file: 'AGENTS.md', changedLines: 1, owners: [] }, + { file: 'packages/core/agent/src/index.ts', changedLines: 8, owners: ['@core', '@second'] }, + { file: 'packages/fs/fs/src/index.ts', changedLines: 3, owners: ['@broad'] }, + ], + reviewers: [ + { login: 'core', changedLines: 8 }, + { login: 'second', changedLines: 8 }, + { login: 'broad', changedLines: 3 }, + ], }, ) }) -test('uses the last matching ownership rule and keeps unmatched files visible', () => { - const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n') - assert.deepEqual( - planReviewers(rules, ['AGENTS.md', 'packages/core/agent/src/index.ts', 'packages/fs/fs/src/index.ts']), - { - matches: [ - { file: 'AGENTS.md', owners: [] }, - { file: 'packages/core/agent/src/index.ts', owners: ['@core', '@second'] }, - { file: 'packages/fs/fs/src/index.ts', owners: ['@broad'] }, - ], - reviewers: ['broad', 'core', 'second'], - }, - ) +test('counts each changed-file record once per owner across rename paths', () => { + const rules = parseOwnership('/packages/a/ @same @a\n/packages/b/ @same @b\n/packages/c/ @c\n') + const plan = planReviewers(rules, [ + { paths: ['packages/a/old.ts', 'packages/b/new.ts'], changedLines: 10 }, + { paths: ['packages/a/other.ts'], changedLines: 5 }, + { paths: ['packages/c/tiny.ts'], changedLines: 1 }, + ]) + assert.deepEqual(plan.reviewers, [ + { login: 'a', changedLines: 15 }, + { login: 'same', changedLines: 15 }, + { login: 'b', changedLines: 10 }, + { login: 'c', changedLines: 1 }, + ]) +}) + +test('rejects invalid changed-file LOC', () => { + for (const file of [ + { filename: 'packages/core/index.ts', deletions: 0 }, + { filename: 'packages/core/index.ts', additions: -1, deletions: 0 }, + { filename: 'packages/core/index.ts', additions: Number.MAX_SAFE_INTEGER, deletions: 1 }, + ]) { + assert.throws(() => classifyChangedFiles([file]), /changed-file|LOC/u) + } }) test('fetches every declared changed file across pages', async () => { @@ -210,21 +335,85 @@ test('fails closed when GitHub cannot provide the complete file list', async () ) }) -test('prints changed code files before requesting missing owners', 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( + listPullRequestTimeline(async () => { + calls++ + return Array.from({ length: 100 }, () => ({ event: 'commented' })) + }, 'owner/repo', 42), + /exceeds 3000 events/u, + ) + assert.equal(calls, 30) +}) + +test('prints changed code files and requests the highest-ranked counted owner', async () => { const trace = [] const files = [ - { filename: 'packages/core/agent/src/index.ts' }, - { filename: 'packages/preset/agent-presets/src/index.ts' }, - { filename: 'packages/client/store/src/index.ts' }, - { filename: 'packages/subagent/subagent/src/index.ts' }, - { filename: 'packages/core/agent/tests/index.spec.ts' }, - { filename: 'AGENTS.md' }, + { filename: 'packages/core/agent/src/index.ts', additions: 70, deletions: 10 }, + { filename: 'packages/preset/agent-presets/src/index.ts', additions: 5, deletions: 5 }, + { filename: 'packages/client/store/src/index.ts', additions: 2, deletions: 0 }, + { filename: 'packages/subagent/subagent/src/index.ts', additions: 40, deletions: 0 }, + { filename: 'packages/core/agent/tests/index.spec.ts', additions: 100, deletions: 0 }, + { filename: 'AGENTS.md', additions: 200, deletions: 0 }, ] 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: [{ login: 'imccyu' }], teams: [] } + return { users: [], teams: [] } } if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} throw new Error(`unexpected API path ${path}`) @@ -239,52 +428,349 @@ test('prints changed code files before requesting missing owners', async () => { assert.deepEqual(result, { changedCodeFiles: [ - 'AGENTS.md', 'packages/client/store/src/index.ts', 'packages/core/agent/src/index.ts', 'packages/preset/agent-presets/src/index.ts', 'packages/subagent/subagent/src/index.ts', ], excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'], - requestedReviewers: ['Dudu-0223', 'LegGasai', 'mektpoy', 'tianyicui'], + excludedDocumentationFiles: ['AGENTS.md'], + excludedCommentOnlyFiles: [], + requestedReviewers: ['mektpoy'], + cancelledReviewers: [], }) assert.equal(trace[0].type, 'log') assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human') const changedHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Changed code files:') + const relevanceHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Owner relevance by changed LOC:') const post = trace.findIndex(item => item.type === 'api' && item.options.method === 'POST') - assert.ok(changedHeading >= 0 && changedHeading < post) + assert.ok(changedHeading >= 0 && changedHeading < relevanceHeading && relevanceHeading < post) + assert.deepEqual(trace.slice(relevanceHeading, relevanceHeading + 6).map(item => item.line), [ + 'Owner relevance by changed LOC:', + '- @turtle1999: 90', + '- @mektpoy: 80', + '- @Dudu-0223: 40', + '- @LegGasai: 10', + '- @imccyu: 2', + ]) assert.deepEqual(trace[post], { type: 'api', path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', options: { method: 'POST', - body: { reviewers: ['Dudu-0223', 'LegGasai', 'mektpoy', 'tianyicui'] }, + body: { reviewers: ['mektpoy'] }, }, }) }) -test('does not request reviewers for a test-only change', async () => { +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 = [] + const result = await requestReviews({ + event: pullRequestEvent(), + ownershipSource: '/packages/core/ @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 [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'first' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) return [] + 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) + assert.deepEqual(output.slice(-7), [ + 'Current individual review requests:', + '- @first', + 'Available counted review request slots: 0.', + 'Review requests to cancel:', + '- (none)', + 'Reviewers to request:', + '- (none)', + ]) +}) + +test('requests at most one owner per run when turtle ranks first', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }), + ownershipSource: '/packages/core/ @turtle1999\n/packages/client/ @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: 25, deletions: 5 }, + { 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: [] } + } + if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }) + + assert.deepEqual(result.requestedReviewers, ['turtle1999']) + assert.deepEqual(calls.find(call => call.options.method === 'POST'), { + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'POST', body: { reviewers: ['turtle1999'] } }, + }) +}) + +test('does not add turtle when one counted reviewer is already requested', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor' }), + ownershipSource: '/packages/core/ @turtle1999 @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 [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'first' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) return [] + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }) + + assert.deepEqual(result.requestedReviewers, []) + assert.equal(calls.some(call => call.options.method === 'POST'), false) +}) + +test('keeps the counted slot available when turtle is already requested', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor' }), + ownershipSource: '/packages/core/ @turtle1999 @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 [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'turtle1999' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) return [] + 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('replaces a workflow reviewer that no longer matches current ownership', async () => { + const trace = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor' }), + ownershipSource: '/packages/core/ @mektpoy\n', + api: async (path, options = {}) => { + trace.push({ type: 'api', 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 [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'Dudu-0223' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) { + return [{ + event: 'review_requested', + requested_reviewer: { login: 'Dudu-0223' }, + review_requester: { login: 'github-actions[bot]' }, + }] + } + if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {} + if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: line => trace.push({ type: 'log', line }), + }) + + assert.deepEqual(result.requestedReviewers, ['mektpoy']) + assert.deepEqual(result.cancelledReviewers, ['Dudu-0223']) + const cancelLog = trace.findIndex(item => item.type === 'log' && item.line === 'Review requests to cancel:') + const requestLog = trace.findIndex(item => item.type === 'log' && item.line === 'Reviewers to request:') + const firstMutation = trace.findIndex(item => item.type === 'api' && item.options.method !== undefined) + assert.ok(cancelLog >= 0 && requestLog >= 0 && cancelLog < firstMutation && requestLog < firstMutation) + assert.equal(trace[cancelLog + 1].line, '- @Dudu-0223') + assert.equal(trace[requestLog + 1].line, '- @mektpoy') + assert.deepEqual(trace.filter(item => item.type === 'api' && item.options.method !== undefined), [ + { + type: 'api', + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } }, + }, + { + type: 'api', + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'POST', body: { reviewers: ['mektpoy'] } }, + }, + ]) +}) + +test('removes excess workflow reviewers using current relevance order', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }), + ownershipSource: '/packages/core/ @mektpoy\n/packages/subagent/ @Dudu-0223\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: 25, deletions: 5 }, + { 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: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) { + return ['Dudu-0223', 'mektpoy'].map(login => ({ + event: 'review_requested', + requested_reviewer: { login }, + review_requester: { login: 'github-actions[bot]' }, + })) + } + if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }) + + assert.deepEqual(result, { + changedCodeFiles: [ + 'packages/core/agent/src/index.ts', + 'packages/subagent/subagent/src/index.ts', + ], + excludedTestFiles: [], + excludedDocumentationFiles: [], + excludedCommentOnlyFiles: [], + requestedReviewers: [], + cancelledReviewers: ['Dudu-0223'], + }) + assert.deepEqual(calls.find(call => call.options.method === 'DELETE'), { + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } }, + }) +}) + +test('does not request reviewers for test, documentation, or comment-only changes', async () => { const calls = [] const output = [] const files = [ - { filename: 'apps/web/tests/chat.e2e.ts' }, - { filename: 'packages/core/agent/tests/agent.spec.ts' }, + { filename: 'apps/web/tests/chat.e2e.ts', additions: 10, deletions: 0 }, + { filename: 'packages/core/agent/tests/agent.spec.ts', additions: 10, deletions: 0 }, + { filename: 'packages/core/agent/README.md', additions: 10, deletions: 0 }, + { filename: 'packages/core/agent/examples.yaml', additions: 10, deletions: 0 }, + { + filename: 'packages/core/agent/src/index.ts', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-// old note\n+// new note', + }, ] const result = await requestReviews({ event: pullRequestEvent({ changedFiles: files.length }), ownershipSource, api: async (path) => { calls.push(path) - return files + if (path.endsWith('/files?per_page=100&page=1')) return files + if (path.endsWith('/requested_reviewers')) return { users: [], teams: [] } + throw new Error(`unexpected API path ${path}`) }, write: line => output.push(line), }) assert.deepEqual(result, { changedCodeFiles: [], - excludedTestFiles: files.map(file => file.filename), + excludedTestFiles: files.slice(0, 2).map(file => file.filename), + excludedDocumentationFiles: files.slice(2, 4).map(file => file.filename), + excludedCommentOnlyFiles: ['packages/core/agent/src/index.ts'], requestedReviewers: [], + cancelledReviewers: [], }) - assert.equal(calls.length, 1) + assert.equal(calls.length, 2) assert.deepEqual(output.slice(0, 4), [ 'This is by automated Angry Turtle Cyborg, not a human', 'Changed code files:', @@ -293,19 +779,67 @@ test('does not request reviewers for a test-only change', async () => { ]) }) -test('defers draft pull requests without reading changed files', async () => { - const output = [] +test('cancels workflow-authored review requests on draft pull requests', async () => { + const trace = [] + const files = [ + { filename: 'packages/subagent/subagent/src/index.ts', additions: 10, deletions: 2 }, + { filename: 'packages/subagent/subagent/tests/index.spec.ts', additions: 10, deletions: 0 }, + { filename: 'packages/subagent/subagent/README.md', additions: 10, deletions: 0 }, + ] const result = await requestReviews({ - event: pullRequestEvent({ draft: true }), + event: pullRequestEvent({ draft: true, changedFiles: files.length }), ownershipSource, - api: async () => assert.fail('draft routing must not call GitHub'), - write: line => output.push(line), + api: async (path, options = {}) => { + trace.push({ type: 'api', path, options }) + if (path.endsWith('/files?per_page=100&page=1')) return files + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'Dudu-0223' }, { login: 'manual-reviewer' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) { + return [ + { + event: 'review_requested', + requested_reviewer: { login: 'Dudu-0223' }, + review_requester: { login: 'maintainer' }, + }, + { + event: 'review_requested', + requested_reviewer: { login: 'Dudu-0223' }, + review_requester: { login: 'github-actions[bot]' }, + }, + { + event: 'review_requested', + requested_reviewer: { login: 'manual-reviewer' }, + review_requester: { login: 'github-actions[bot]' }, + }, + { + event: 'review_requested', + requested_reviewer: { login: 'manual-reviewer' }, + review_requester: { login: 'maintainer' }, + }, + ] + } + if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: line => trace.push({ type: 'log', line }), }) - assert.deepEqual(result, { changedCodeFiles: [], excludedTestFiles: [], requestedReviewers: [] }) - assert.deepEqual(output, [ - 'This is by automated Angry Turtle Cyborg, not a human', - 'Draft pull request; reviewer routing is deferred until ready_for_review.', - ]) + assert.deepEqual(result, { + changedCodeFiles: ['packages/subagent/subagent/src/index.ts'], + excludedTestFiles: ['packages/subagent/subagent/tests/index.spec.ts'], + excludedDocumentationFiles: ['packages/subagent/subagent/README.md'], + excludedCommentOnlyFiles: [], + requestedReviewers: [], + cancelledReviewers: ['Dudu-0223'], + }) + const remove = trace.find(item => item.type === 'api' && item.options.method === 'DELETE') + assert.deepEqual(remove, { + type: 'api', + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } }, + }) + assert.equal(trace.some(item => item.type === 'log' && item.line === '- @manual-reviewer'), false) + assert.equal(trace.at(-1).line, 'Cancelled review request for @Dudu-0223.') }) test('sends authenticated JSON and escapes an API error body', async () => { 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..1097042f7d --- /dev/null +++ b/.github/workflows/node-addon-system.yml @@ -0,0 +1,179 @@ +# 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. 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 }} + compatibility: ${{ steps.matrix.outputs.compatibility }} + steps: + - uses: actions/checkout@v4 + + - id: matrix + run: | + echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT" + echo "compatibility=$(node ./scripts/github-matrix.mjs compatibility)" >> "$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 + run: pnpm test:packaging + + - name: Flock behavior (built addon) + run: | + pnpm build:test-oracle + pnpm test:flock + + - name: Upload this platform's built addon and entry + uses: actions/upload-artifact@v4 + with: + name: system-compat-${{ matrix.platform }} + path: | + native/system/packages/*/bin/** + native/system/packages/entry/lib/** + if-no-files-found: error + + - name: Upload independent syscall test oracle + uses: actions/upload-artifact@v4 + with: + name: system-oracle-${{ matrix.platform }} + path: native/system/test/bin/** + if-no-files-found: error + + compatibility: + name: ${{ matrix.platform }} / Node ${{ matrix.node }} (same binary) + needs: [matrix, native] + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.matrix.outputs.compatibility) }} + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: Download the original platform build + uses: actions/download-artifact@v4 + with: + name: system-compat-${{ matrix.platform }} + path: native/system/packages + + - name: Download independent syscall test oracle + uses: actions/download-artifact@v4 + with: + name: system-oracle-${{ matrix.platform }} + path: native/system/test/bin + + - name: Restore oracle executable permissions + run: find ./test/bin -type f -name flock-oracle -exec chmod +x {} + + + - name: Test without rebuilding or installing dependencies + run: | + node ./test/link-platform.mjs + node --test ./test/flock.test.js ./test/package-matrix.test.js + + - name: Test the same musl addon without a compiler + if: runner.os == 'Linux' + run: >- + docker run --rm -v "$PWD:$PWD" -w "$PWD" + node:${{ matrix.node }}-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/request-review.yml b/.github/workflows/request-review.yml index 0d202b3288..6dc839ac5b 100644 --- a/.github/workflows/request-review.yml +++ b/.github/workflows/request-review.yml @@ -2,7 +2,7 @@ name: request-review on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] permissions: contents: read @@ -15,7 +15,6 @@ concurrency: jobs: request-review: name: request-review - if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest timeout-minutes: 5 steps: 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/.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 a43b428f74..43c4263136 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -71,7 +71,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 | @@ -90,6 +89,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`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 | @@ -122,18 +122,18 @@ 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 @@ -153,9 +153,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/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -222,4 +222,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/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..7b41f6017e 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,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":"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":"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..c2d9145aaf 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,26 @@ {"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":"tool/result","seq":17,"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":18,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":19,"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":20,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"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":22,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":23,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} +{"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":"user/message","seq":25,"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":26,"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":27,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":28,"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/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 47946e5b22..6de8fd4428 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -54,6 +54,7 @@ "typescript": "^6.0.3", "vite": "^6.0.0", "vitest": "^4.1.8", - "ws": "8.21.0" + "ws": "8.21.0", + "@deepseek-ai/dsh-launch-environment": "workspace:^" } } 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..ea3b6969a8 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') diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 3e00a67cb0..c010943b60 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -16,6 +16,13 @@ - paragraph: - img "Remote test image" - paragraph: Local test image +- paragraph: + - img "Workspace test image" +- paragraph: Oversized image +- paragraph: + - img "Outside workspace image" +- paragraph: Missing image +- paragraph: {{cwd}}/corrupt.png - paragraph: REMOTE_IMAGE_DONE - button "Copy": - img diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index 9e4a7be60e..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() @@ -81,6 +81,8 @@ describe('web e2e: /feedback command acknowledgement', () => { await input.press('Enter') await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1) + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') + await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) const expanded = await captureExpandedTurnProcessAria( @@ -96,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 1530c35c80..fe9b498822 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -139,9 +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) - await vi.waitFor(() => { expect(scaffold.ctx.agents.list()).toHaveLength(before + 1) }) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(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 282aa0b1a9..30d9f922be 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -23,14 +23,15 @@ import { launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, + connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE, } 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') +const COMMAND_MENU_ZH_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-zh.expected.md') const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md') @@ -103,6 +104,26 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await expect.poll(() => menu.count()).toBe(0) }) + it.skipIf(MODE === 'record')('localizes slash-command descriptions from the browser language', async () => { + const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const zhTripwire = watchConsole(zhPage) + onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-command-menu-zh')) + try { + await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const launcher = zhPage.getByRole('button', { name: '指令' }) + await launcher.click() + const menu = zhPage.getByRole('listbox', { name: '触发候选建议' }) + await menu.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE) + expect(zhTripwire.pageErrors).toEqual([]) + expect(zhTripwire.warnings).toEqual([]) + } finally { + await zhPage.close() + } + }) + it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { const activeScaffold = await launchWebScaffold() const activePage = await newEnglishPage(browser) @@ -184,6 +205,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') { @@ -199,8 +231,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) } } @@ -328,6 +363,14 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/) const connectingGeometry = await connectionIndicatorGeometry(connecting) expect(await connectionIndicatorTextAlignment(connecting)).toBe('left') + // Animated dots must remain hidden with their state label during hover. + await connecting.evaluate((element) => { + for (const animation of element.getAnimations({ subtree: true })) { + if (!(animation instanceof CSSAnimation)) continue + animation.pause() + animation.currentTime = 1_250 + } + }) await connecting.hover() expect(await connecting.innerText()).toBe('Reconnect now') expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry) @@ -414,8 +457,9 @@ 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', - 'command-menu-fuzzy.expected.md', 'connection-error.expected.md', 'hero.expected.md', 'plan-active.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 3f6f192507..3df0703839 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. @@ -182,7 +182,10 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { await queuedRow.getByRole('button', { name: 'Remove queued message' }).click() await expect.poll(() => queuedRow.count(), { timeout: 10_000 }).toBe(0) - await page.getByRole('button', { name: 'Stop generating' }).click() + const stopButton = page.getByRole('button', { name: 'Stop generating' }) + await stopButton.hover() + await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() + await stopButton.click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') // Composer recovered; no streaming node lingers. The host settled first @@ -190,6 +193,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // frozen-partial swap is eventually consistent, so poll rather than count. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => page.getByRole('tooltip').count()).toBe(0) // Golden of the aborted end-state: the prompt bubble plus the frozen // partial ('partial' is the hang entry's replayed prefix) and no more. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) @@ -328,7 +332,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-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 54ac8ddc9e..b8f18c6c67 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -1,8 +1,7 @@ -// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session -// assembled through the Session API is seeded cold into the real web -// composition, then a separate image origin proves that the browser receives -// a real network image while local-path Markdown remains inert alt text. +// Real browser image loading and failure fallbacks through the shipped Web composition. +import { open, writeFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' @@ -32,6 +31,7 @@ const MODE = webSnapshotMode() const SEED_ID = 'markdown-images-web-e2e' const REMOTE_ALT = 'Remote test image' const LOCAL_ALT = 'Local test image' +const WORKSPACE_ALT = 'Workspace test image' const PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', @@ -81,7 +81,7 @@ async function stopServer(server: Server): Promise { } /** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ -function markdownImageFixture(remoteUrl: string): string { +function markdownImageFixture(remoteUrl: string, outsidePath: string): string { const session = Session.create(SessionId('markdown-image-source')) const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) session.append('turn/start', { turn: 1 }) @@ -110,6 +110,16 @@ function markdownImageFixture(remoteUrl: string): string { '', `![${LOCAL_ALT}](./local-image.png)`, '', + `![${WORKSPACE_ALT}]({{cwd}}/valid.png)`, + '', + '![Oversized image]({{cwd}}/oversized.png)', + '', + `![Outside workspace image](${outsidePath})`, + '', + '![Missing image]({{cwd}}/missing.png)', + '', + '![]({{cwd}}/corrupt.png)', + '', 'REMOTE_IMAGE_DONE', ].join('\n'), }], @@ -142,20 +152,38 @@ function markdownImageFixture(remoteUrl: string): string { ].join('\n') } -describe('web e2e: remote Markdown image rendering', () => { +describe('web e2e: Markdown image rendering', () => { let scaffold: WebScaffold let imageOrigin: ImageOrigin let browser: Browser let page: Page let tripwire: ReturnType + const mediaResponses = new Map() beforeAll(async () => { imageOrigin = await startImageOrigin() scaffold = await launchWebScaffold({}) - await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID) + await writeFile(join(scaffold.workspaceCwd, 'valid.png'), PNG) + await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image') + const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w') + try { + await oversized.truncate(20 * 1024 * 1024 + 1) + } finally { + await oversized.close() + } + const outsidePath = join(scaffold.persistenceRoot, 'outside.png') + await writeFile(outsidePath, PNG) + await writeFile(join(scaffold.workspaceCwd, 'active.html'), '

File preview

') + await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) + page.on('response', (response) => { + const url = new URL(response.url()) + if (url.pathname !== '/api/file') return + const path = url.searchParams.get('path') + if (path !== null) mediaResponses.set(path, response.status()) + }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -163,17 +191,30 @@ describe('web e2e: remote Markdown image rendering', () => { afterAll(async () => { await browser?.close() await scaffold?.close() - await stopServer(imageOrigin.server) + if (imageOrigin !== undefined) await stopServer(imageOrigin.server) }) - it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => { + it('authenticates file requests and isolates directly opened active content', async () => { + const path = `/api/file?path=${encodeURIComponent(join(scaffold.workspaceCwd, 'active.html'))}` + const unauthenticated = await fetch(new URL(path, scaffold.baseUrl)) + expect(unauthenticated.status).toBe(401) + await unauthenticated.body?.cancel() + const preview = await newEnglishPage(browser) + await preview.context().addCookies(await page.context().cookies()) + try { + const response = await preview.goto(new URL(path, scaffold.baseUrl).href) + expect(response?.status()).toBe(200) + await preview.getByText('File preview', { exact: true }).waitFor() + expect(await preview.locator('body').getAttribute('data-script-ran')).toBeNull() + } finally { + await preview.close() + } + }) + + it.skipIf(MODE === 'record')('loads permitted images and shows authored text for failures', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() + await page.getByRole('treeitem').first().click() + await page.getByRole('treeitem').nth(1).click() await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), { timeout: 15_000, }).toBe(1) @@ -203,6 +244,27 @@ describe('web e2e: remote Markdown image rendering', () => { expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) + const workspaceImage = page.getByRole('img', { name: WORKSPACE_ALT }) + await expect.poll(() => mediaResponses.get(join(scaffold.workspaceCwd, 'valid.png'))).toBe(200) + await expect.poll(() => workspaceImage.evaluate(element => (element as HTMLImageElement).naturalWidth, undefined, { + timeout: 1_000, + })) + .toBe(1) + const outsideImage = page.getByRole('img', { name: 'Outside workspace image' }) + await expect.poll(() => outsideImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) + for (const alt of ['Oversized image', 'Missing image']) { + await page.getByText(alt, { exact: true }).waitFor() + expect(await page.getByRole('img', { name: alt }).count()).toBe(0) + } + await page.getByText(join(scaffold.workspaceCwd, 'corrupt.png'), { exact: true }).waitFor() + expect(mediaResponses).toEqual(new Map([ + [join(scaffold.workspaceCwd, 'valid.png'), 200], + [join(scaffold.workspaceCwd, 'oversized.png'), 413], + [join(scaffold.persistenceRoot, 'outside.png'), 200], + [join(scaffold.workspaceCwd, 'missing.png'), 404], + [join(scaffold.workspaceCwd, 'corrupt.png'), 200], + ])) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) 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 01373d8dc3..11707cfaf1 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -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 @@ -72,6 +79,8 @@ describe('minimal agent preset', () => { 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) @@ -103,7 +112,7 @@ 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), @@ -163,7 +172,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/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/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index ebaec71502..97cf82cd19 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -212,6 +212,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByLabel('上下文窗口 3').fill('131072') await settings.getByLabel('最大输出 token 数 3').fill('64K') + await expect.poll( + () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'), + { timeout: 10_000 }, + ).toBe('已配置——输入新值可替换') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) await settings.getByRole('button', { name: '保存', exact: true }).click() diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts new file mode 100644 index 0000000000..7323495984 --- /dev/null +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -0,0 +1,73 @@ +/** SSH launch behavior over a recorded conversation and the shipped Web plugin rows. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +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.v3.jsonl', import.meta.url)) +const SEED_ID = 'open-in-app-ssh-web-e2e' +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([ + { source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }, + ]), + }) + await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.addInitScript(() => { + localStorage.setItem('dsh.open-in-app.choice', JSON.stringify('vscode')) + }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length > 0) throw new AggregateError(failures, 'Open In SSH scenario teardown failed') + }) + + it('hides a remembered app after the real host returns an empty catalog', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-open-in-app-ssh')) + const [response] = await Promise.all([ + page.waitForResponse(response => new URL(response.url()).pathname === '/open-in-app/apps'), + (async () => { + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const group = page.getByRole('treeitem').first() + await group.waitFor() + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + })(), + ]) + expect(response.status()).toBe(200) + expect(await response.json()).toEqual({ apps: [] }) + expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0) + expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0) + expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"') + const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'header.expected.md'), snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['header.expected.md']) + }) +}) 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/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 086bbce416..7c4ce8d4bf 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -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) @@ -147,37 +159,49 @@ describe('web e2e: queue row actions', () => { ).toBe(2) await page.setViewportSize({ width: 640, height: 1000 }) - const queueBox = await page.locator('[data-queue-dock]').boundingBox() - const composerBox = await page.locator('[data-composer-card]').boundingBox() - expect(queueBox).not.toBeNull() - expect(composerBox).not.toBeNull() - expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x) - expect(queueBox!.x + queueBox!.width) - .toBeLessThanOrEqual(composerBox!.x + composerBox!.width) - const queueLeftInset = queueBox!.x - composerBox!.x - const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width - const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => { - const style = getComputedStyle(element) - return { - dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')), - } - }) - expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1) - expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1) + await page.locator('[data-sidebar-collapsed="true"]').waitFor() + // The responsive sidebar and composer settle independently; sample both + // rectangles in one browser task so the comparison uses one layout. + await vi.waitFor(async () => { + const metrics = await page.evaluate(() => { + const queue = document.querySelector('[data-queue-dock]') + const composer = document.querySelector('[data-composer-card]') + if (queue === null || composer === null) return undefined + const queueBox = queue.getBoundingClientRect() + const composerBox = composer.getBoundingClientRect() + return { + leftInset: queueBox.left - composerBox.left, + rightInset: composerBox.right - queueBox.right, + dockInset: Number.parseFloat(getComputedStyle(composer).getPropertyValue('--dsh-composer-dock-inset')), + } + }) + expect(metrics).toBeDefined() + expect(metrics!.leftInset).toBeGreaterThanOrEqual(0) + expect(metrics!.rightInset).toBeGreaterThanOrEqual(0) + expect(metrics!.leftInset).toBeCloseTo(metrics!.dockInset, 1) + expect(metrics!.rightInset).toBeCloseTo(metrics!.dockInset, 1) + }, { timeout: 10_000 }) await page.setViewportSize({ width: 1680, height: 1000 }) const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT }) await editRow.getByRole('button', { name: 'Edit queued message' }).click() const editor = page.getByRole('textbox', { name: 'Edit queued message' }) await editor.fill(EDITED) + await page.getByRole('button', { name: 'Save queued message' }).hover() + 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 }) + await expect.poll(() => remainingEdit.isEnabled(), { timeout: 10_000 }).toBe(true) + await remainingEdit.hover() + await page.getByRole('tooltip', { name: 'Edit queued message', exact: true }).waitFor() const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) @@ -215,16 +239,18 @@ describe('web e2e: queue row actions', () => { { timeout: 10_000 }, ).toBe(2) - await page.getByRole('button', { name: 'Stop generating' }).click() + const stopButton = page.getByRole('button', { name: 'Stop generating' }) + await stopButton.hover() + await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() + await stopButton.click() await firstSettled await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count()) .toBe(0) await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count()) .toBe(2) - // Stop becomes Send under the pointer; dismiss its hover tooltip before capture. - await page.mouse.move(0, 0) - await expect.poll(() => page.getByRole('tooltip').filter({ hasText: 'Send message' }).count()).toBe(0) + // The disabled Send button must dismiss the active Stop tooltip without mouseleave. + await expect.poll(() => page.getByRole('tooltip').count()).toBe(0) const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE) const expanded = await captureExpandedTurnProcessAria( 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 2b66a6035b..4bc734aa4b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -31,6 +31,7 @@ import { pathToFileURL } from 'node:url' import type { Page } from 'playwright' import { expect } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' @@ -46,7 +47,7 @@ import { redactSessionSnapshotIds, normalizeSessionSnapshots, parseSessionFixtureName, - scrubRequestHeaders, + scrubModelRequestBulk, scrubSessionSnapshot, sessionFixtureFiles, sessionFixtureName, @@ -284,6 +285,8 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { + /** Enable the real Open In rows with deterministic launch-environment facts. */ + openInAppEnvironment?: LaunchEnvironmentSnapshot /** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */ compareReplaySession?: boolean /** @@ -600,13 +603,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise() const stopObservingSessions = ctx.on('session/created', (session) => { observedSessions.set(session.id, session) @@ -1079,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. @@ -1554,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 2f59edc165..933d15035c 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({ @@ -461,7 +461,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' }) @@ -501,6 +502,9 @@ describe('web e2e: seeded history renders through cold resume', () => { const userId = userLine?.match(/^Anonymous user: ([0-9a-f-]+)/i)?.[1] if (userId === undefined) throw new Error('feedback command omitted the user id') + // command/done can arrive before the submit reply releases the composer. + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') + await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') .split(userId).join('{{userId}}') @@ -540,7 +544,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 f3be2fcd39..e617c9aead 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -10,7 +10,7 @@ // 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 { join } from 'node:path' @@ -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') 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..9457407c0a 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') @@ -291,7 +291,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 08f4c5e193..d665afddb6 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -7,7 +7,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, 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 type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -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', () => { @@ -32,6 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = let page: Page let tripwire: ReturnType let prompt: string + const releaseChild = Promise.withResolvers() const waitForParentSettlement = (): Promise => new Promise((resolve, reject) => { let dispose = (): void => {} @@ -53,9 +54,14 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = scaffold = await launchWebScaffold({ replayFixture: PARENT_FIXTURE, replayChildFixtures: [CHILD_FIXTURE], - paceMs: 50, compareReplaySession: false, }) + // Keep the live child available throughout disclosure, layout, and navigation checks. + scaffold.ctx.on('llm/stream', async function* (options, next) { + const session = options.sessionId === undefined ? undefined : scaffold.ctx.sessions.get(options.sessionId) + if (session?.header.origin === 'subagent') await releaseChild.promise + yield* next() + }, { prepend: true }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -65,6 +71,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = }, 120_000) afterAll(async () => { + releaseChild.resolve(undefined) await browser?.close() await scaffold?.close() }) @@ -72,6 +79,9 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live')) const settled = waitForParentSettlement() + onTestFinished(() => { + releaseChild.resolve(undefined) + }) const input = page.locator('[data-composer-input]').first() await input.fill(prompt) await input.press('Enter') @@ -104,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' @@ -160,6 +172,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click() + releaseChild.resolve(undefined) await settled await expandTurnProcesses(page) await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor() diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index d591dbe3b0..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 } @@ -77,12 +80,16 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff () => scaffold.ctx.workspaceRegistry.resolveByPath(join(parent, name)), { timeout: 10_000 }, ).not.toBeUndefined() + // Adoption also opens a blank Session. Its selected row must reach the + // browser before a later workspace action can depend on the row positions. + const row = page.getByRole('treeitem').filter({ hasText: name }).first() + const section = row.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]') + await section.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 10_000 }) } /** - * 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 @@ -100,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 cdfc17fae0..1e3fbcd850 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", @@ -65,6 +66,7 @@ "tests/web-search-round.e2e.ts", "tests/file-upload-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/open-in-app-ssh.e2e.ts", "tests/message-feedback.e2e.ts", "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", diff --git a/benchmarks/agent-continuation/README.i18n.yaml b/benchmarks/agent-continuation/README.i18n.yaml index 565f8ef6a7..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: 6c682f506c5ea62004a885731dd56e3a679a1d86 -README.zh.md: 123fe54f1175e166d83f38ad27df4a46abb6721b +README.md: cb3d99d749922e08d493f0b58dab04c63356024a +README.zh.md: 30331aa886d75f8582c6e386cbb022bb487dbe9f diff --git a/benchmarks/agent-continuation/README.md b/benchmarks/agent-continuation/README.md index 6c682f506c..cb3d99d749 100644 --- a/benchmarks/agent-continuation/README.md +++ b/benchmarks/agent-continuation/README.md @@ -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 123fe54f11..30331aa886 100644 --- a/benchmarks/agent-continuation/README.zh.md +++ b/benchmarks/agent-continuation/README.zh.md @@ -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/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 d52edac87b..3957eee85a 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: 12e308b54e0a3cded9ef06f2963a3b6fb3c993b8 -config-catalog.zh.md: 42ea196d28ea8e628e31a235ef71760ece18f6ab +config-catalog.md: 9099ca894f0cab6fb8030c5ffced74cf7547df77 +config-catalog.zh.md: a9d7502de051857bb947361c1808a48ebcedb2ee diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 12e308b54e..9099ca894f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:70`](../packages/api/session-controller/src/index.ts) @@ -948,7 +948,7 @@ export interface Config { } ``` -Source: [`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts) +Source: [`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts) @@ -1081,10 +1081,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) @@ -1433,12 +1439,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 +1896,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) @@ -2990,8 +2998,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 +3049,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) @@ -3561,6 +3569,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 42ea196d28..a9d7502de0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -580,7 +580,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 +772,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 +824,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) @@ -950,7 +950,7 @@ export interface Config { } ``` -来源:[`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts) +来源:[`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts) @@ -1083,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 } ``` -依赖:[`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) @@ -1435,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 } ``` -依赖:[`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 +1898,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) @@ -2992,8 +3000,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 /** @@ -3562,6 +3570,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..f0492f3a9f 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: 7c33981cb58d4bad7c1b410fcf61d4649f6322c1 +event-producer-consumer.zh.md: d857561a7d9dbdf4c4d971a1854d43d3ecc4fd2a diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 99811d1dac..7c33981cb5 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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 68ea7460ee..d857561a7d 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) | diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index fe64fd1e42..2ffdac7b4e 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/i18n/README.md -README.md: 1b6ed4a3f4bff05cdb52b28fc81c5d7b47a9a260 -README.zh.md: e28db9bb230cf5fb344fdc3ddc415883f5ebe6b3 +README.md: 55ae07c18e09fde141ecf5344f715dfa25658325 +README.zh.md: 674edeb9da4bf0083c607216a3c992a98f61897a diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 1b6ed4a3f4..55ae07c18e 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -51,6 +51,7 @@ Generated English references and graphs participate in pairing when a reviewed C - `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. +- [review-ownership/README.md](../../.github/review-ownership/README.md) and its [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) — repository-internal automation policy maintained in English only. - `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them. **Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index e28db9bb23..674edeb9da 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -53,6 +53,7 @@ - `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 +- [review-ownership/README.md](../../.github/review-ownership/README.md) 及其 [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md):仓库内部自动化政策,只以英文维护。 - `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。 **统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7789f3c308..ba1dd07a2f 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: ecb72f02bc7a27a75318e8abf56c20f9915831ea -module-graph.zh.md: 5c94c866d10e81948dd69089210cd4b08ded7531 +module-graph.md: 233ae6b3fb49b07a2f7237aef2674c782df07720 +module-graph.zh.md: 64c5aa749aa76631c308d4b724ed1cd356068019 diff --git a/docs/module-graph.md b/docs/module-graph.md index ecb72f02bc..233ae6b3fb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -298,6 +298,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"] @@ -1070,6 +1071,7 @@ flowchart TD pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_fs pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command @@ -1257,6 +1259,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` | — | @@ -1419,7 +1422,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 5c94c866d1..64c5aa749a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -300,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"] @@ -1072,6 +1073,7 @@ flowchart TD pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_fs pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command @@ -1259,6 +1261,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` | — | @@ -1421,7 +1424,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 6775871bcd..1e60882ff3 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: b239204810f92d866d309214678a22901e6f3ef6 +persistence-catalog.zh.md: 86d81a5a64322c98ea288c0aa0289550153eedf0 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8d6f66dc94..b239204810 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/*` @@ -587,13 +582,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 +609,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 +684,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 +733,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 +746,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,7 +757,7 @@ 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/*` @@ -800,6 +797,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 +912,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 +934,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 +982,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 +1068,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 +1084,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 +1103,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..86d81a5a64 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/*` @@ -414,7 +409,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 +420,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 +539,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 +584,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 +611,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 +686,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 +735,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 +748,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,7 +759,7 @@ 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/*` @@ -802,6 +799,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 +836,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 +854,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 +867,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 +880,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 +914,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 +936,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 +984,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 +1070,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 +1086,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 +1105,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/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..d59028fa8b 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: 35a4db80d17205f91342523c8413054743fb7481 +llm-streaming.zh.md: 5e3ddc55ab2972e3e2a318baf1e37e0013d324e8 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 97062fb326..35a4db80d1 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -511,7 +511,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 +562,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 +576,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 +699,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 +756,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..5e3ddc55ab 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -517,7 +517,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 +568,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 +582,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 +705,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 +762,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/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 3c53237bad..f64a9d5c91 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: 55e8f8af23cb71c1103c017c09e9dfd2ef365b75 +subagent.zh.md: 71a71b33771ff83ccaa6802358b8534c11930181 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index cf711185d0..55e8f8af23 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -471,11 +471,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 diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index e97b4ab965..71a71b3377 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -475,11 +475,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 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/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..16319731c8 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: c85201523e66408e6aa536de1f141df650055265 +tool-catalog.zh.md: ce5f5b57f6ac6f6a3db6a1503967fc7ceb7c48fd diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1a98079dcf..c85201523e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -16,7 +16,7 @@ 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-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. | diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 41b5f6d4bc..ce5f5b57f6 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -20,7 +20,7 @@ | 工具包 | 模型可见名称 | 依赖 | 写入/影响 | 随产品发布的别名 | 部署说明 | | --- | --- | --- | --- | --- | --- | | `@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-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`。 | 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/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/.github/review-ownership/README.i18n.yaml b/native/system/packages/entry/README.i18n.yaml similarity index 55% rename from .github/review-ownership/README.i18n.yaml rename to native/system/packages/entry/README.i18n.yaml index e9ed0b794d..6c10cabb1c 100644 --- a/.github/review-ownership/README.i18n.yaml +++ b/native/system/packages/entry/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 .github/review-ownership/README.md -README.md: 95dd52595bacb9b2b00bc475966f328b06098a14 -README.zh.md: a20a5f4ae41b5d0418601afa853ac2fbc141cc96 +# 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 20d0c93390..820a6764d3 100644 --- a/package.json +++ b/package.json @@ -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,8 +48,9 @@ "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", @@ -102,6 +103,7 @@ "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", + "verify-package-readme-summaries": "tsx scripts/verify-package-readme-summaries.ts", "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-agent-note-classification": "tsx scripts/verify-agent-note-classification.ts", "verify-agent-note-format": "tsx scripts/verify-agent-note-format.ts", diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index d0ba9d7e87..4be563b5f5 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/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/acp/acp/README.md -README.md: 6f0411d993f67f96d599a811ce583f8e166b76b6 -README.zh.md: 641e0801ce85caefa90c0d9fdd32c3886ada82b8 +README.md: 635e0e6993a65f403a5cc89f2c9d147b48308a04 +README.zh.md: 65d7d3b2f9f66701c801c910048a501f565b0a46 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 6f0411d993..635e0e6993 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-acp` lets trusted programs drive persistent DeepSeek Harness agents over the standard [Agent Client Protocol](https://agentclientprotocol.com): create or resume sessions, list resumable sessions, attach standard MCP servers, select a model and reasoning effort, prompt or cancel work, receive semantic execution updates, and close one session without affecting others. It is built for automation — out-of-process subagents, test runners, and scripted controllers — rather than the DSH user interface: it emits standard ACP messages, thoughts, generic tool lifecycle, configuration, and context usage, never private DSH presentation data or methods. Session persistence enables list, resume, and close across process restarts, while deletion, fork, transcript replay, additional directories, and interactive UI surfaces remain unsupported. The repository's own ACP client is `dsh-subagent-acp`, and `pnpm dsh --profile acp` starts a ready-to-use server. Setup and usage come first; the implementation details live in a collapsible developer section below. +`dsh-acp` lets trusted programs automate persistent DeepSeek Harness agents through the standard [Agent Client Protocol](https://agentclientprotocol.com): create or resume sessions, select a model and reasoning effort, attach MCP servers, submit or cancel work, receive semantic updates, and close sessions independently. Choose it for out-of-process subagents, test runners, and scripted controllers; it intentionally omits DSH-specific presentation data and interactive UI features. Persistence supports listing, resuming, and closing sessions across process restarts, but deletion, forks, transcript replay, and additional directories are unsupported. Run `pnpm dsh --profile acp` to start the server; use `dsh-subagent-acp` as the repository client. ## Table of Contents diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index 641e0801ce..65d7d3b2f9 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-acp` 让受信程序可以通过标准 [Agent Client Protocol(ACP)](https://agentclientprotocol.com) 驱动持久 DeepSeek Harness agent:创建或恢复会话、列出可恢复会话、挂载标准 MCP 服务器、选择模型与推理强度、发送或取消工作、接收语义执行更新,并关闭一个会话而不影响其他会话。它是为自动化而生的——进程外 subagent、测试运行器与脚本化控制器——而不是 DSH 用户界面:它发送标准 ACP 消息、thought、通用工具生命周期、配置与上下文用量,绝不发送 DSH 私有呈现数据或方法。会话持久化支持跨进程重启的列出、恢复与关闭,而删除、fork、转录回放、附加目录与交互式 UI 界面仍不支持。仓库自带的 ACP 客户端是 `dsh-subagent-acp`,`pnpm dsh --profile acp` 会启动一个开箱即用的服务器。设置与用法在前;实现细节放在下方可折叠的开发者章节中。 +`dsh-acp` 让受信程序通过标准 [Agent Client Protocol(ACP)](https://agentclientprotocol.com) 自动操作持久 DeepSeek Harness agent:创建或恢复会话、选择模型与推理强度、挂载 MCP 服务器、提交或取消工作、接收语义更新,并独立关闭会话。进程外 subagent、测试运行器与脚本化控制器适合选择它;它刻意不提供 DSH 专用呈现数据与交互式 UI 功能。持久化支持跨进程重启列出、恢复与关闭会话,但不支持删除、fork、转录回放与附加目录。运行 `pnpm dsh --profile acp` 可启动服务器;仓库客户端使用 `dsh-subagent-acp`。 ## 目录 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/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/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..1687df643c 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -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 47da2a25c0..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: 8f7de90c2029117296bca02ce13dc5e769471434 -README.zh.md: b99589c4d7ed0a0105874bcbbd37dfd03927918d +README.md: ef6ffb35636f9f1a87c3b30832f68540d5dcd0ea +README.zh.md: 97949e01379446e6171debb9fb1250e8088413f8 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 8f7de90c20..ef6ffb3563 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -13,6 +13,7 @@ English | [中文](README.zh.md) ## Table of Contents - [Use this package](#use-this-package) +- [Session media references](#session-media-references) - [Configuration](#configuration) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) @@ -25,12 +26,20 @@ 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. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. + + +## Session media references + +`SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. + ----- @@ -57,6 +66,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages. +- The image byte cap does not validate decoded dimensions or pixel count. - Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b99589c4d7..97949e0137 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -13,6 +13,7 @@ kind: "package-reference" ## 目录 - [使用本包](#use-this-package) +- [会话媒体引用](#session-media-references) - [配置](#configuration) - [模型体验](#model-experience) - [已知限制与延期工作](#known-limitations-and-deferred-work) @@ -25,12 +26,20 @@ 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 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 + + +## 会话媒体引用 + +当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与 sandbox CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 + ----- @@ -57,6 +66,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 +- 图片字节上限不校验解码后的尺寸或像素数。 - Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 - 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第一个字节重新传输整个文件。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 65b757e0e1..19b5339da6 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-deque": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", + "mime-types": "^3.0.2", "zod": "^4.4.3" }, "peerDependencies": { @@ -84,6 +85,7 @@ "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", @@ -125,9 +127,11 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-file-upload": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", @@ -150,6 +154,7 @@ "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-util-values": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^" + "@deepseek-ai/dsh-workspace": "workspace:^", + "@types/mime-types": "^3.0.1" } } 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/index.ts b/packages/api/session-controller/src/index.ts index c058f25a08..9cc93be179 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -22,6 +22,7 @@ import { ApiSessionList } from './list.ts' import { buildModelCatalog } from './catalog.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' import { SessionSkillCatalog } from './skill-catalog.ts' +import { SessionMediaReferences } from './media-references.ts' import type { ModelCatalog, SessionAttachmentRequest, @@ -134,6 +135,7 @@ export class SessionController extends TypertRemoteService { this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) + ctx.plugin(SessionMediaReferences) ctx.plugin(SessionSkillCatalog) ctx.on('session/created', (session) => { diff --git a/packages/api/session-controller/src/media-references.ts b/packages/api/session-controller/src/media-references.ts new file mode 100644 index 0000000000..9da80029f5 --- /dev/null +++ b/packages/api/session-controller/src/media-references.ts @@ -0,0 +1,77 @@ +/** + * Authenticated GET/HEAD /api/file reads bounded file responses through + * the composed filesystem provider. Paths and MIME types do not restrict access; + * the connection service authenticates requests before this handler. + * @module @deepseek-ai/dsh-api-session-controller/media-references + */ + +import { isAbsolute } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-connection' +import type {} from '@deepseek-ai/dsh-attachment' +import { FsError, type FileSystem } from '@deepseek-ai/dsh-fs' +import mime from 'mime-types' + +const BASE_HEADERS = { + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + // HTML and SVG files may be opened directly on the authenticated API origin. + 'Content-Security-Policy': "sandbox; default-src 'none'", +} + +async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise { + const fail = (status: number, text: string): Response => + new Response(request.method === 'HEAD' ? null : text, { status, headers: BASE_HEADERS }) + const path = new URL(request.url).searchParams.get('path') + if (path === null || path.length === 0) return fail(400, 'missing path') + if (path.includes('\0') || !isAbsolute(path)) return fail(400, 'absolute path required') + try { + const target = await fs.resolve(path, { signal: request.signal }) + const mediaType = mime.lookup(target.displayPath) || 'application/octet-stream' + const headers: Record = { + ...BASE_HEADERS, + 'Content-Type': mediaType, + } + if (request.method === 'HEAD') { + const info = await fs.stat(target, request.signal) + if (info === undefined) return fail(404, 'not found') + if (info.type !== 'file') return fail(403, 'not a regular file') + if (info.size !== undefined) { + if (info.size > maxBytes) return fail(413, 'file exceeds byte limit') + headers['Content-Length'] = String(info.size) + } + return new Response(null, { headers }) + } + const bytes = await fs.readBytes(target, request.signal, maxBytes) + headers['Content-Length'] = String(bytes.byteLength) + return new Response(bytes.slice(), { headers }) + } catch (error: unknown) { + if (!(error instanceof FsError)) throw error + const statuses: Partial> = { + FS_NOT_FOUND: 404, + FS_NOT_REGULAR_FILE: 403, + FS_PERMISSION_DENIED: 403, + FS_SANDBOX_DENIED: 403, + FS_TOO_LARGE: 413, + FS_ABORTED: 499, + } + return fail(statuses[error.code] ?? 500, error.code) + } +} + +/** + * File-display contribution. The connection service supplies authentication; + * `ctx.fs` supplies the execution world's paths, reads, and access policy. + */ +export const SessionMediaReferences = { + inject: ['connection', 'fs', 'attachments'], + apply(ctx: Context): void { + const maxBytes = ctx.attachments.imageLimits.maxImageBytes + ctx.effect(() => ctx.connection.fetch.register({ + path: '/api/file', + methods: ['GET', 'HEAD'], + requestBody: 'buffered', + fetch: request => serveFile(request, ctx.fs, maxBytes), + }), 'session-controller: /api/file') + }, +} 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/media-references.host.spec.ts b/packages/api/session-controller/tests/media-references.host.spec.ts new file mode 100644 index 0000000000..61572ab0be --- /dev/null +++ b/packages/api/session-controller/tests/media-references.host.spec.ts @@ -0,0 +1,224 @@ +import { appendFile, mkdir, mkdtemp, open, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { SessionMediaReferences } from '../src/media-references.ts' + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]) +const DEFAULT_LIMIT = 20 * 1024 * 1024 + +async function responseBytes(response: Response): Promise { + return new Uint8Array(await response.arrayBuffer()) +} + +describe('SessionMediaReferences /api/file', () => { + let root: string + const contexts: Context[] = [] + + beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-'))) + }) + + afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await rm(root, { recursive: true, force: true }) + }) + + async function mount(maxBytes = DEFAULT_LIMIT) { + const ctx = new Context() + contexts.push(ctx) + let handler: ((request: Request) => Promise) | undefined + const unregister = vi.fn(() => {}) + ctx.provide('connection', { + fetch: { + register: (registered: { fetch: (request: Request) => Promise }) => { + handler = registered.fetch + return unregister + }, + }, + } as never) + ctx.provide('attachments', { imageLimits: { maxImageBytes: maxBytes } } as never) + await ctx.plugin(LocalFileSystem, { cwd: root }).await() + await ctx.plugin(SessionMediaReferences).await() + const raw = (url: string, init?: RequestInit) => { + if (handler === undefined) throw new Error('route not registered') + return handler(new Request(url, init)) + } + return { + call: (path: string, init?: RequestInit) => raw(`http://127.0.0.1/api/file?path=${encodeURIComponent(path)}`, init), + raw, + fs: ctx.fs as LocalFileSystem, + unregister, + dispose: () => ctx.fiber.dispose(), + } + } + + it('serves the inclusive image cap and refuses larger images for GET, HEAD and Range', async () => { + const route = await mount(PNG_BYTES.length) + const path = join(root, 'bounded.png') + await writeFile(path, PNG_BYTES) + expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) + await appendFile(path, new Uint8Array(1)) + expect((await route.call(path)).status).toBe(413) + expect((await route.call(path, { headers: { range: 'bytes=0-0' } })).status).toBe(413) + const head = await route.call(path, { method: 'HEAD' }) + expect(head.status).toBe(413) + expect(head.body).toBeNull() + }) + + it('rejects a sparse 1 GiB image before content I/O', async () => { + const route = await mount() + const inspect = vi.fn() + route.fs.internals.inspectReadBytesAfterStat = inspect + const path = join(root, 'huge.png') + const handle = await open(path, 'w') + try { + await handle.truncate(1024 * 1024 * 1024) + } finally { + await handle.close() + } + expect((await route.call(path)).status).toBe(413) + expect(inspect).not.toHaveBeenCalled() + }) + + it('uses the filesystem byte reader to reject post-stat image growth', async () => { + const route = await mount(PNG_BYTES.length) + const path = join(root, 'growing.png') + await writeFile(path, PNG_BYTES) + route.fs.internals.inspectReadBytesAfterStat = async () => { + await appendFile(path, new Uint8Array(1)) + } + expect((await route.call(path)).status).toBe(413) + }) + + it.each([ + ['png', 'image/png'], ['svg', 'image/svg+xml'], ['mp4', 'video/mp4'], ['mp3', 'audio/mpeg'], + ['txt', 'text/plain'], ['html', 'text/html'], ['bin', 'application/octet-stream'], ['', 'application/octet-stream'], + ])('serves .%s files with their MIME type and response protections', async (extension, mediaType) => { + const route = await mount() + const path = join(root, `file${extension === '' ? '' : `.${extension}`}`) + await writeFile(path, PNG_BYTES) + const response = await route.call(path) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe(mediaType) + expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length)) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect(response.headers.get('content-security-policy')).toBe("sandbox; default-src 'none'") + expect(await responseBytes(response)).toEqual(PNG_BYTES) + }) + + it.each(['mp4', 'mp3', 'bin'])('applies the attachment byte cap to .%s files', async (extension) => { + const route = await mount(PNG_BYTES.length) + const path = join(root, `file.${extension}`) + await writeFile(path, PNG_BYTES) + expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) + await appendFile(path, new Uint8Array(1)) + expect((await route.call(path)).status).toBe(413) + expect((await route.call(path, { method: 'HEAD' })).status).toBe(413) + }) + + it('ignores Range headers and returns complete bodies without advertising ranges', async () => { + const route = await mount() + const path = join(root, 'clip.mp4') + await writeFile(path, PNG_BYTES) + for (const range of ['bytes=0-3', 'bytes=-4', 'bytes=999-', 'bytes=abc', 'items=0-0', 'bytes=0-1,3-4']) { + const response = await route.call(path, { headers: { range } }) + expect(response.status).toBe(200) + expect(response.headers.get('accept-ranges')).toBeNull() + expect(response.headers.get('content-range')).toBeNull() + expect(await responseBytes(response)).toEqual(PNG_BYTES) + } + }) + + it('answers HEAD without reading content and reports missing and non-regular files', async () => { + const route = await mount() + const path = join(root, 'image.png') + await writeFile(path, PNG_BYTES) + const read = vi.spyOn(route.fs, 'readBytes') + const response = await route.call(path, { method: 'HEAD', headers: { range: 'bytes=0-3' } }) + expect(response.status).toBe(200) + expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length)) + expect(response.body).toBeNull() + expect(read).not.toHaveBeenCalled() + expect((await route.call(join(root, 'missing'), { method: 'HEAD' })).status).toBe(404) + expect((await route.call(root, { method: 'HEAD' })).status).toBe(403) + vi.spyOn(route.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v1') }) + expect((await route.call(path, { method: 'HEAD' })).headers.get('content-length')).toBeNull() + }) + + it('rejects malformed paths, absent files, and directories', async () => { + const route = await mount() + expect((await route.raw('http://127.0.0.1/api/file')).status).toBe(400) + for (const path of ['', 'relative.png', '/a\0b.png']) { + expect((await route.call(path)).status).toBe(400) + } + const head = await route.call('', { method: 'HEAD' }) + expect(head.status).toBe(400) + expect(head.body).toBeNull() + expect((await route.call(join(root, 'missing.png'))).status).toBe(404) + await mkdir(join(root, 'frames.png')) + expect((await route.call(join(root, 'frames.png'))).status).toBe(403) + }) + + it('reads files and symlink targets outside the default cwd without a workspace registry', async () => { + const route = await mount() + const outside = await mkdtemp(join(tmpdir(), 'dsh-media-outside-')) + try { + const path = join(outside, 'image.png') + await writeFile(path, PNG_BYTES) + expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) + const link = join(root, 'linked.png') + await symlink(path, link) + expect(await responseBytes(await route.call(link))).toEqual(PNG_BYTES) + } finally { + await rm(outside, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform === 'win32')('rejects a FIFO before opening it', async () => { + const route = await mount() + const path = join(root, 'stream.png') + const { execFile } = await import('node:child_process') + const { promisify } = await import('node:util') + await promisify(execFile)('mkfifo', [path]) + expect((await route.call(path)).status).toBe(403) + }) + + it('reads opaque remote targets through ctx.fs and preserves provider failures', async () => { + const route = await mount() + const target = { targetKey: FsTargetKey('opaque-remote-id'), displayPath: '/remote/photo.png' } + vi.spyOn(route.fs, 'resolve').mockResolvedValue(target) + const read = vi.spyOn(route.fs, 'readBytes').mockResolvedValue(PNG_BYTES) + expect(await responseBytes(await route.call('/remote/photo.png'))).toEqual(PNG_BYTES) + expect(read).toHaveBeenCalledWith(target, expect.any(AbortSignal), DEFAULT_LIMIT) + for (const [code, status] of [ + ['FS_PERMISSION_DENIED', 403], ['FS_SANDBOX_DENIED', 403], ['FS_NOT_FOUND', 404], + ['FS_NOT_REGULAR_FILE', 403], ['FS_TOO_LARGE', 413], ['FS_IO_ERROR', 500], + ] as const) { + read.mockRejectedValueOnce(new FsError('provider rejected read', code)) + expect((await route.call('/remote/photo.png')).status).toBe(status) + } + read.mockRejectedValueOnce(new Error('provider bug')) + await expect(route.call('/remote/photo.png')).rejects.toThrow('provider bug') + }) + + it('serves an empty file and respects an aborted request', async () => { + const route = await mount() + const path = join(root, 'empty.png') + await writeFile(path, '') + const response = await route.call(path) + expect(response.headers.get('content-length')).toBe('0') + expect(await response.text()).toBe('') + expect((await route.call(path, { signal: AbortSignal.abort() })).status).toBe(499) + }) + + it('unregisters the route on disposal', async () => { + const route = await mount() + await route.dispose() + expect(route.unregister).toHaveBeenCalledTimes(1) + }) +}) 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/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 85366bd632..45a22e93cb 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -17,6 +17,7 @@ "src/file-references.ts", "src/history.ts", "src/list.ts", + "src/media-references.ts", "src/model-selection-projection.ts", "src/skill-catalog.ts" ], @@ -30,8 +31,10 @@ { "path": "../../context/file-reference" }, { "path": "../../attachment/attachment" }, { "path": "../../client/file-upload/tsconfig.host.json" }, + { "path": "../../client/connection/tsconfig.host.json" }, { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, + { "path": "../../fs/fs" }, { "path": "../../llm/llm" }, { "path": "../../util/deque" }, { "path": "../../util/native-command" }, diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 80c6207706..3d4b3525e8 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: f7442845bc3c592bee0c59817a72ad07c8c91a2a -README.zh.md: 4acd022335ee7c276aca00d66e177c19b060df0a +README.md: ec15f52bf17fffca2b225fa427a7405922aaf2b3 +README.zh.md: 7e45d5c9a6073435e26e1237791a328db654d839 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index f7442845bc..ec15f52bf1 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-workspace-files` owns the Host `ctx.workspaceFiles` service and the generated Client `workspaceFiles` Remote namespace: `read` returns one page of lines from a UTF-8 text file, `readBytes` returns one window of raw bytes from any regular file, `stat` returns a file's version and size without its content, `list` returns one directory's direct children, and `changes` streams every filesystem observation an Agent makes inside the Session's workspace root. All five run over the composed `ctx.fs` and confine themselves to the workspace root the sandbox policy resolves for the addressed Session; the filesystem backend's own cwd never decides. Client packages reach the namespace through the [`api-remotes`](../../api/remotes/README.md) assembly. The package's `./client` export registers the `file` resource provider that turns `stat` and `changes` into live file metadata for `useResource<'file'>`; the Sidebar's file tree tab lists directories through `list`. +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. ## Table of Contents diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 4acd022335..7e45d5c9a6 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-workspace-files` 拥有 Host 侧 `ctx.workspaceFiles` 服务与生成的 Client 侧 `workspaceFiles` Remote 命名空间:`read` 返回一个 UTF-8 文本文件的一页行,`readBytes` 返回任意普通文件的一个原始字节窗口,`stat` 返回文件的版本与大小而不带内容,`list` 返回一个目录的直接子项,`changes` 流式推送 Agent 在 Session 工作区根内做出的每一次文件系统观察。五者都经组合后的 `ctx.fs` 运行,并把自己限定在沙箱策略为被寻址 Session 解析出的工作区根内;文件系统后端自己的 cwd 从不参与判定。Client 包经 [`api-remotes`](../../api/remotes/README.zh.md) 装配触达该命名空间。本包的 `./client` 导出注册 `file` 资源提供者,把 `stat` 与 `changes` 变成 `useResource<'file'>` 的实时文件元数据;Sidebar 的文件树 tab 经 `list` 列举目录。 +使用本包可从 Web Client 浏览和检查 Session 工作区内的文件。它按行分页读取 UTF-8 文本、按有界窗口读取原始字节、报告文件版本与大小、列举目录的直接子项,并流式推送 Agent 文件操作造成的变更。每项操作都限定在为被寻址 Session 选择的工作区根内,不受文件系统后端工作目录影响。Client 组件还可经共享 Remote API 跟随实时文件元数据并构建 Sidebar 文件树。 ## 目录 diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 9c5f3a1abb..f6a61238ee 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: 364153b7b56daa725003178b6cfad90e3f94bc04 -README.zh.md: 6ca5c6df8289c9e16bfe608b5b9ae200adf18a6b +README.md: d4b8037c5e5cdcd9cd39302422d74ef854fb0890 +README.zh.md: 37d03f5edda1311f51968fe66f928cb19886514c diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 364153b7b5..d4b8037c5e 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately, and generic files are saved byte-for-byte with no admission limits. Streamed file writes and reads use bounded chunks; writes hash into a private staging object before atomic publication, and reads verify the recorded byte length and digest without a whole-file memory copy. It is what the shipped `dsh` composition uses, so durable attachments work without configuration. Identical bytes occupy one canonical object even when uploads use different display names; each model-facing name is a hard link to that object. Concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine; other hosts cannot read these objects, and objects are never deleted automatically. +Store images and generic file attachments durably below `DSH_HOME` on the machine running DSH. Images are validated, normalized for model requests, and cached per route; generic files are preserved byte-for-byte without admission limits. Identical bytes are stored once even when uploads use different display names, reads verify file length and content, and admitted images remain readable if limits later tighten. The shipped `dsh` composition uses this package without configuration. Objects remain local to one machine and are never deleted automatically. ## Table of Contents diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 6ca5c6df82..37d03f5edd 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存,通用文件则不设准入限制,按字节原样保存。流式文件写入与读取都使用有界分块;写入会在私有暂存对象中计算摘要后原子发布,读取会校验记录的字节长度与摘要,两者都不产生整文件内存副本。随附的 `dsh` 组合使用的就是它,因此持久附件无需配置即可工作。即使使用不同显示名称上传,相同字节也只占用一个规范对象;每条模型可见路径都是指向该对象的硬链接。同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机,其他主机无法读取这些对象,对象也永远不会自动删除。 +在运行 DSH 的机器上,把图片与通用文件附件持久存储到 `DSH_HOME` 下。图片经过校验、针对模型请求完成规范化并按路由缓存;通用文件不设准入限制,按字节原样保存。即使上传时使用不同显示名称,相同字节也只存储一次;读取会校验文件长度与内容,之后收紧限制也不会让已接纳的图片不可读。随附的 `dsh` 组合无需配置即可使用本包。对象仅限本机,并且永远不会自动删除。 ## 目录 diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index d92fc02572..9924b1e0d2 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: a812182aeff6d09506a1ea2d4fa8d9a44a175936 -README.zh.md: c487f8204c86d8f0bbdfd85280e8fbab6ea14dec +README.md: fc3903cb1ab4ed4a1249ad2ec62c0df633f4a7c4 +README.zh.md: 9e84a5ed8889d541b3cb87fb5e5d5560d36a2bb5 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index a812182aef..fc3903cb1a 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -You can attach images and generic files to prompts, and the harness keeps them durably: each source image is admitted and normalized before your message is processed, while any other file is stored byte-for-byte with no format or size limits, and both reappear in conversation history across restarts of the same session. The shipped `dsh` composition enables this with no setup. Browser paths, provider URLs, local storage paths, and base64 never enter durable session events. Images accept raster formats (PNG, JPEG, WebP, GIF) under deployment limits; files accept anything, and the model reads a stored file on demand from its saved read-only path instead of receiving its bytes. Stored objects are never deleted automatically, and audio and video have no dedicated handling yet. +Attach images and generic files to prompts and commands, then reuse them after restarting the same session, without extra setup in the shipped `dsh` composition. Images are validated and normalized before the message is accepted; PNG, JPEG, WebP, and GIF are supported within deployment limits. Other files are stored byte-for-byte without format or size limits, and models read them on demand through saved read-only paths instead of receiving their bytes. Durable session events exclude browser paths, provider URLs, local storage paths, and base64. Stored attachments are never deleted automatically; audio and video have no dedicated handling. ## Table of Contents diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index c487f8204c..9e84a5ed88 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -你可以把图片和通用文件附加到提示词中,harness 会持久保存它们:每张源图都会在你的消息被处理前准入并规范化,而其他任何文件都按字节原样保存、不设格式与大小限制,两者都会在同一会话重启后重新出现在对话历史中。随附的 `dsh` 组合无需任何配置即可支持这一点。浏览器路径、提供方 URL、本地存储路径与 base64 绝不会进入持久会话事件。图片接受部署限额内的光栅格式(PNG、JPEG、WebP、GIF);文件接受任何内容,模型不接收文件字节,而是在需要时从保存的只读路径按需读取。已存储对象永远不会被自动删除,音频和视频暂无专门处理。 +把图片与通用文件附加到提示词和命令中,同一会话重启后仍可复用;随附的 `dsh` 组合无需额外配置。图片会在消息被接受前完成校验与规范化;部署限额内支持 PNG、JPEG、WebP 和 GIF。其他文件按字节原样保存,不设格式与大小限制;模型通过保存的只读路径按需读取,而不接收文件字节。持久会话事件不包含浏览器路径、提供方 URL、本地存储路径和 base64。已存储附件不会被自动删除;音频和视频暂无专门处理。 ## 目录 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 9e3e8f5bc5..e119e03f59 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: fff0ba4df85b7ea834a79087ecbfe9f1e27f7714 -README.zh.md: 345db4a86ed2088a998c1723c3f906c614a171f3 +README.md: f0c6636b342d856b44c9d5eaffbbd4657f3e88ae +README.zh.md: 31246ada165bad830bf2f5808fe9c6e1ad91d7cb diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index fff0ba4df8..f0c6636b34 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-cmdline` lets your app own its command line: the launcher keeps only its own flags (`--profile`, `--patch`, the config dumps) and passes everything after them to your app verbatim, so your app decides its flags, its `--help` text, and its parse errors. Values you parse from those arguments win over any default written in the config, without writing anything back. Your app also gets a bounded way to ask for process exit, wired to the launcher's shutdown. Use it when you write an app bin that accepts its own flags; it adds no prompt, schema, or model-facing surface of its own. +`dsh-cmdline` lets an app parse its own flags, `--help`, and errors from the arguments left unchanged after launcher flags. Parsed values can override configuration defaults without rewriting configuration. The app can also request process exit through the launcher's shutdown path. Use this package for app bins with their own command-line interface. It adds no prompt, schema, or model-visible content. ## Table of Contents diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 345db4a86e..31246ada16 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-cmdline` 让你的应用持有自己的命令行:启动器只保留属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给你的应用,因此 flag、`--help` 文本与解析错误都由你的应用决定。你从这些参数解析出的值会胜过配置中写下的任何默认值,且无需写回任何内容。你的应用还获得一个有边界的进程退出请求,接到启动器的关停上。当你编写接受自有 flag 的应用 bin 时使用它;它本身不增加任何提示词、schema 或面向模型的表面。 +`dsh-cmdline` 让应用从启动器 flag 之后原样留下的参数中解析自己的 flag、`--help` 与错误。解析值可以覆盖配置默认值,而无需改写配置。应用还可以通过启动器的关停路径请求进程退出。适用于拥有自有命令行界面的应用 bin。它不增加提示词、schema 或模型可见内容。 ## 目录 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/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index e5b2cc3b72..e04aaa88d7 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/web-app/README.md -README.md: 0f71be178c25c0e6687a6e51ff777a9d6ac76a5a -README.zh.md: ea7747c0b814dc36d222d0d7445732159f589d7b +README.md: aea694942173a856861d00a69f15b451cc930976 +README.zh.md: f1b402c985ec2c21afdd67f8cb5477196aafeb44 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 0f71be178c..aea6949421 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Run `dsh --profile web` and the interface opens in your default browser, ready for interactive chat with the agent. You get the conversation view, model and settings management, and session history, backed by the same model access, tools, and safety defaults as every other surface. The command prints a tokenized startup URL; the browser exchanges that token for a signed session cookie and redirects to the clean root URL. You can change the port, suppress the browser handoff, and allow extra hosts from the command line; binding all network interfaces is intentionally not supported. Choose it for interactive work in the browser; `dsh-headless` is the one-shot command-line sibling. +Run `dsh --profile web` to open an interactive browser GUI with chat, model and settings management, and session history. It uses the same model access, tools, and safety defaults as other dsh surfaces. Startup prints an authenticated URL and normally opens it in the default browser; SSH sessions and `--no-open` leave the URL for manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Choose this package for interactive browser work; use `dsh-headless` for one-shot command-line tasks. ## Table of Contents diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index ea7747c0b8..f1b402c985 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -运行 `dsh --profile web`,界面会在你的默认浏览器中打开,即可与 agent(智能体)交互式聊天。你会获得会话视图、模型与设置管理以及会话历史,背后与其他表层相同的模型访问、工具与安全默认值。该命令会打印带 token 的启动 URL;浏览器用该 token 换取签名会话 cookie,再重定向到干净的根 URL。你可以从命令行更改端口、关闭浏览器交接并允许额外主机;有意不支持绑定所有网络接口。需要浏览器中的交互式工作时选择它;`dsh-headless` 是一次性的命令行兄弟表层。 +运行 `dsh --profile web`,打开提供聊天、模型与设置管理以及会话历史的交互式浏览器 GUI。它使用与其他 dsh 表层相同的模型访问、工具与安全默认值。启动时会打印经过认证的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 会保留该 URL,供你手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。需要在浏览器中交互式工作时选择本包;一次性的命令行任务应使用 `dsh-headless`。 ## 目录 diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 4f0367a44f..63edf46529 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -21,7 +21,7 @@ import z from '@deepseek-ai/schemastery' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' -import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -81,15 +81,6 @@ const LOOPBACK_HOST = '127.0.0.1' /** The webserver schema's all-interfaces bind literal. */ const ALL_INTERFACES_HOST = '0.0.0.0' -/** Whether this process was launched through SSH, including a forwarded-port session. */ -function launchedThroughSsh(ctx: Context): boolean { - const environment = launchEnvironmentOf(ctx) - return ['SSH_CONNECTION', 'SSH_TTY'].some((name) => { - const value = environment.getFrom(name, ['process'])?.value - return value !== undefined && value !== '' - }) -} - const BROWSER_OPENER_MODULE = import.meta.resolve('open') const BROWSER_OPENER_PROGRAM = ` @@ -235,7 +226,7 @@ export function apply(ctx: Context, config: Config): void { const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts) // The loopback URL belongs to this host. Under SSH, the operator reaches it // through a local forwarding address that this process cannot derive. - const handoffBrowser = config.openBrowser && !launchedThroughSsh(ctx) + const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(ctx)) // Release dependent rows only after bind-dependent trust has been sampled once. ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 85dc4fcffd..c23797ba1f 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: aec7edcb1e15d174544a9abaf99a4dc784034f2a -README.zh.md: e4f069e1afef4973ebc8fdcc507a720c7a02be79 +README.md: 6bb433b8411fa9db3d6de981a24895e3c7b674c4 +README.zh.md: c04292b86becba404e5dbb58ca924833ee88f58b diff --git a/packages/client/README.md b/packages/client/README.md index aec7edcb1e..6bb433b841 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `client/` group runs the browser half of the dsh web GUI: it boots the web shell, loads browser-side plugin modules, keeps browser-to-host RPC and event delivery alive, and provides the shared client services and UI feature plugins that render the application. UI features compose through the slot system — each plugin fills declared extension slots with typed props and stores, and the shell renders the assembled tree. All packages here are product packages named `@deepseek-ai/dsh-client-`; the host half that serves the page lives in [`host/`](../host/README.md). Authoring rules live in [AGENTS.md](AGENTS.md), and the module graph, slot model, and object layer are documented in the related notes below. +The `client/` group provides the browser experience for the dsh web GUI, including conversation, navigation, settings, approvals, file access, and other interactive features. Choose packages from this family when adding browser-visible behavior; use [`host/`](../host/README.md) for server-side page delivery and host integration. Packages cover both the shared browser foundation and focused UI features, while each child README owns its configuration and behavior. Authoring rules live in [AGENTS.md](AGENTS.md), and the related documentation below explains cross-package composition. ## Table of Contents diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index e4f069e1af..c04292b86b 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`client/` 组运行 dsh web GUI 的浏览器侧:它启动 web 外壳、加载浏览器侧插件模块、维持浏览器与宿主之间的 RPC 与事件投递,并提供渲染应用所需的共享客户端服务与 UI 功能插件。UI 功能通过 slot 系统组合——每个插件填充已声明的扩展 slot,携带类型化 props 与 store,由外壳渲染组装后的整棵树。本组所有包均为产品包,名为 `@deepseek-ai/dsh-client-`;服务于页面的宿主半侧位于 [`host/`](../host/README.zh.md)。编写规则见 [AGENTS.md](AGENTS.md),模块图、slot 模型与对象层的说明见下方相关文档。 +`client/` 组提供 dsh web GUI 的浏览器体验,包括对话、导航、设置、批准、文件访问及其他交互功能。添加浏览器中可见的行为时,请选择本系列中的包;服务端页面交付与宿主集成则使用 [`host/`](../host/README.zh.md)。本系列同时涵盖共享浏览器基础与专门的 UI 功能,各子包 README 拥有其配置与行为说明。编写规则见 [AGENTS.md](AGENTS.md),下方相关文档解释跨包组合方式。 ## 目录 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/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index a159c6cc4f..518649ba23 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/locale/README.md -README.md: da0931ff5cf78b16d57354a8ac6abe6bf1878e50 -README.zh.md: 18eb233e80ba8a68621b2fa34442cdda3c4329a0 +README.md: 56a9cff9c3ec18dcc395378fcd1691207c022284 +README.zh.md: b23a1f2da59d0d83213d9c38e7cee05843881274 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index da0931ff5c..56a9cff9c3 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-locale` localizes the web GUI: users choose from the registered languages in Settings → General, and the UI copy switches immediately. The package ships `zh` and `en`, while external client plugins can add languages and their namespace dictionaries. On a loopback page, the choice persists as `locale.preference` in `$DSH_HOME/settings.yaml`; a non-loopback page keeps its selection process-local even though Connection authenticates every API method. A fresh browser starts provisionally in the first registered language requested by `navigator` until an allowed Host preference arrives and replaces it live. Plugin authors receive full type checking for the built-in dictionary form and translate through the framework `t` seat; copy rendered through slots follows language switches without a reload. +Use `dsh-client-locale` to switch the web GUI between the shipped English and Chinese locales or languages added by client plugins. User selections take effect immediately; loopback pages persist them in `$DSH_HOME/settings.yaml`, while non-loopback pages keep them only for the current process. New browsers use the first supported language requested by the browser until an allowed stored preference arrives. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload. ## Table of Contents diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 18eb233e80..b23a1f2da5 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-locale` 为 web GUI 提供本地化:用户在“设置 → 常规”中从已注册语言中选择,UI 文案会立即切换。本包内置 `zh` 与 `en`,外部 client 插件可以增加语言及其命名空间字典。在 loopback 页面上,该选择以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;非 loopback 页面即使由 Connection 认证所有 API 方法,也只在进程内保留选择。全新浏览器会先临时使用 `navigator` 请求的第一个已注册语言,直到允许读取的 Host 偏好到达并实时替换。插件作者使用内置字典形式时会获得完整类型检查,并通过框架 `t` 席位翻译;经 slot 渲染的文案会随语言切换即时更新。 +使用 `dsh-client-locale` 可在 web GUI 中切换内置的 English、中文 locale,或 client 插件添加的语言。用户选择会立即生效;loopback 页面把选择持久化到 `$DSH_HOME/settings.yaml`,非 loopback 页面则只为当前进程保留选择。全新浏览器会使用浏览器请求的第一个受支持语言,直到允许读取的已存储偏好到达。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。 ## 目录 diff --git a/packages/client/resources/README.i18n.yaml b/packages/client/resources/README.i18n.yaml index 50ca50a65e..34a20c84f9 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: 2bc3d03bc5c58d45f9a0955aa73185be87b2bc6c -README.zh.md: 43238fd5ff3794204f8d6d989e5d771131b8c489 +README.md: 7c32293c8d4250b11f8beaaf473a62145c915bce +README.zh.md: b53089a2449f379f43156f610b587656a998ece3 diff --git a/packages/client/resources/README.md b/packages/client/resources/README.md index 2bc3d03bc5..7c32293c8d 100644 --- a/packages/client/resources/README.md +++ b/packages/client/resources/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -The resource model of the web client. A resource is one address, and a resource address is a `dsh-resource:///…` URL whose host is the protocol key; the protocol's owning client package registers a provider that turns an address into a value stream, and any slot component reads that stream through the `useResource` global standard hook. A protocol that needs a scope encodes it in the path (`dsh-resource://file/session//`); the model knows only addresses, and an address under any other scheme (`sidebar://guide`) names no resource. Use it when a component needs live data it only knows by address (a tab record, a link, a mention) and the data's owner is another client plugin. +Use client resources when a component knows live data only by URL address, such as a tab record, link, or mention, while another client package owns the data. Resource addresses use `dsh-resource:///…`; protocols that need a scope encode it in the path. Components receive the current value and later updates through the public `useResource` hook. Unsupported protocols and non-resource schemes, such as `sidebar://guide`, resolve to no resource. ## Table of Contents diff --git a/packages/client/resources/README.zh.md b/packages/client/resources/README.zh.md index 43238fd5ff..b53089a244 100644 --- a/packages/client/resources/README.zh.md +++ b/packages/client/resources/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Web 客户端的资源模型。一份资源是一个地址,资源地址是 `dsh-resource:///…` 形式的 URL,host 即协议键;协议所属的客户端包注册一个提供方把地址变成值的流,任何 slot 组件通过 `useResource` 全局标准 hook 读取这条流。需要作用域的协议把它编进路径(`dsh-resource://file/session//<绝对路径>`);模型本身只认地址,其它 scheme 的地址(`sidebar://guide`)不指向资源。当组件需要的活数据只以地址形式可知(tab 记录、链接、提及),而数据的拥有者是另一个客户端插件时,请使用它。 +当组件只知道活数据的 URL 地址,而数据由另一个客户端包拥有时,请使用客户端资源;例如 tab 记录、链接或提及。资源地址使用 `dsh-resource:///…`;需要作用域的协议把作用域编进路径。组件通过公开的 `useResource` hook 接收当前值与后续更新。不支持的协议与非资源 scheme(例如 `sidebar://guide`)不指向任何资源。 ## 目录 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 4bfd6aaaf7..a1aa8931e9 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 06f2bc703633069a40b4677d4d84c12f4cc2444c -README.zh.md: a9fecb68fd4cddd192a02667e58133e696e345c8 +README.md: df5a46ae7d1668483b60538cffc4fc1b851163bc +README.zh.md: b83957ea7d1e8e79ec72070505ed24cacfb01864 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 06f2bc7036..df5a46ae7d 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the agent-preset surfaces of the Web GUI: a chip on the new-session screen choosing the next session's preset, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. A session's preset is fixed at creation, so the choice applies to sessions started afterwards while running sessions keep the composition they began with; the default preset is edited in the settings section, where the roster is visible, so General settings carries no duplicate control for the same field. When a deployment composes no presets, all three surfaces render nothing and every session shares the host composition. +Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition. ## Table of Contents diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index a9fecb68fd..b83957ea7d 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 Web GUI 的 agent preset 表面:新建会话界面的一枚 chip,选择下一个会话的 preset;会话标题旁的一个只读标签;以及一个设置分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。会话的 preset 在创建时即固定,因此选择作用于此后开启的会话,运行中的会话保持它们开始时的组装;默认 preset 在能看到名单的设置分区里编辑,通用设置不再为同一字段保留重复控件。当部署未组装任何 preset 时,三个表面都不渲染任何内容,每个会话共用宿主组装。 +使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。 ## 目录 diff --git a/packages/client/ui-brand-official/README.i18n.yaml b/packages/client/ui-brand-official/README.i18n.yaml index c74b4ca3e5..65850053e8 100644 --- a/packages/client/ui-brand-official/README.i18n.yaml +++ b/packages/client/ui-brand-official/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-brand-official/README.md -README.md: 0176d78feac7eafa3a99a570a515ad1d753fd686 -README.zh.md: 0879e25fffce4973c4b741ddcdb5fa0e6a6ebbdb +README.md: f8687047ca3b2a88d4fb2ae36a27819852df5ee9 +README.zh.md: 94477d966defce6ec4c3f4536ecdb7ae96389a31 diff --git a/packages/client/ui-brand-official/README.md b/packages/client/ui-brand-official/README.md index 0176d78fea..f8687047ca 100644 --- a/packages/client/ui-brand-official/README.md +++ b/packages/client/ui-brand-official/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package fills the sidebar brand slots — `sidebar.brand.mark` and `sidebar.brand.name` — with the official DeepSeek Harness mark and name. It registers these occupants only when the client bundle builds with the `official` profile; every other build loads the plugin but registers nothing, so the shell fallbacks stay visible. The conversation hero slot (`conversation.hero.brand.mark`) stays unoccupied in every build: its declaring package renders the animated hero fish (hover swim morph) as the fallback, and the official brand is that fish. Choose this package when the deployed identity is DeepSeek's own; a deployment with its own brand composes a different package into the same slots instead. It retains no runtime state and contributes nothing to model requests. +This package gives an `official` client build the DeepSeek Harness mark and name in the sidebar. Other build profiles keep the shell's fish mark and local-build label, while the conversation hero always uses the animated fish. Choose it for deployments branded as DeepSeek Harness; deployments with another identity should provide a replacement brand package. It has no runtime state and does not affect model requests. ## Table of Contents diff --git a/packages/client/ui-brand-official/README.zh.md b/packages/client/ui-brand-official/README.zh.md index 0879e25fff..94477d966d 100644 --- a/packages/client/ui-brand-official/README.zh.md +++ b/packages/client/ui-brand-official/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向侧栏品牌槽位——`sidebar.brand.mark` 与 `sidebar.brand.name`——填充官方 DeepSeek Harness 标志与名称。它只在客户端以 `official` profile 构建时注册这些填充;其余构建同样加载插件但不注册任何内容,因此外壳回退保持可见。会话首屏槽位(`conversation.hero.brand.mark`)在所有构建中都保持无填充:其声明包以动画首屏鱼(悬停游动形变)作为回退渲染,而官方品牌正是这条鱼。当部署身份就是 DeepSeek 自身时选择本包;自有品牌的部署改为在相同槽位中组合另一个包。它不保留任何运行时状态,也不向模型请求贡献任何内容。 +本包让以 `official` profile 构建的客户端在侧栏显示 DeepSeek Harness 标志与名称。其他构建 profile 保留外壳的鱼形标志与本地构建标签,会话首屏则始终使用动画鱼。品牌为 DeepSeek Harness 的部署应选择本包;使用其他品牌的部署应提供替代品牌包。本包不保留运行时状态,也不影响模型请求。 ## 目录 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index a7b4221628..4985ac3796 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: 5dd3a1c2a52789522629a822c8d522365fe93054 -README.zh.md: 07e8c2a9d41fa3a5d4f1a40e4570b9864822547f +README.md: b5a837f927b483a1f72e8282032fce29683bc237 +README.zh.md: f6b8018434251e46875bfd5cc0b0a242cdf2a439 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 5dd3a1c2a5..b5a837f927 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic. +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. ## Table of Contents @@ -25,9 +25,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin ## 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 07e8c2a9d4..f6b8018434 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。 +使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript 与 steering 提交会立即显示并保留在原区域,在权威 Session 记录到达时原子地消失,而 queued 提交始终不进入 Chat。本包不组装或修改模型请求。 ## 目录 @@ -25,7 +25,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition ## 系统提示词行 -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/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index c517f9d0c2..3212801e00 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -1,7 +1,7 @@ import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' -import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' @@ -9,6 +9,22 @@ import { ReasoningRow } from './ReasoningRow.tsx' import { useSearchableHidden } from './searchable-hidden.ts' import css from './AssistantMarkdown.module.css' +/** + * Map one authored media destination to the same-origin workspace-file URL. + * @param protocol - `window.location.protocol` at render time. + * @param origin - `window.location.origin` at render time. + * @param value - The authored markdown destination, exactly as written. + * @returns The API URL for an absolute POSIX path on an HTTP(S) page, or + * undefined when the destination cannot be a Host-served local file + * (non-HTTP transport such as Electron `file://`, protocol-relative or + * relative destinations). + */ +export function localPathMediaUrl(protocol: string, origin: string, value: string): string | undefined { + if (protocol !== 'http:' && protocol !== 'https:') return undefined + if (value.length === 0 || !value.startsWith('/') || value.startsWith('//')) return undefined + return `${origin}/api/file?path=${encodeURIComponent(value)}` +} + export interface AssistantMarkdownProps { blocks: readonly AssistantBlock[] streaming: boolean @@ -34,6 +50,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const labels = useMemo(() => markdownLabels(t), [t]) + // Local media paths in the closing prose rewrite to the same-origin file + // API (policy re-validation lives host-side). The vocabulary identity is + // stable per page load because MarkdownText memoizes on it. + const pathImages = useMemo(() => { + const { protocol, origin } = window.location + return { resolve: value => localPathMediaUrl(protocol, origin, value) } + }, []) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -55,6 +78,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ streaming={streaming} labels={labels} fileMentions={mentions} + pathImages={pathImages} />, ) break 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/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 e426a56a14..51c7bf6072 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/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx new file mode 100644 index 0000000000..564f5a6a41 --- /dev/null +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { AssistantMarkdown, localPathMediaUrl } from '../src/client/chat/AssistantMarkdown.tsx' +import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../src/client/contract/slots.ts' +import type { AssistantBlock } from '../src/client/contract/snapshot.ts' + +afterEach(cleanup) + +const t = ((_key: string) => 'label') as unknown as ChatViewSlotProps['t'] +const renderMessageImages = (() => null) as unknown as ChatNodeOwnerProps['renderMessageImages'] + +function textBlock(text: string): AssistantBlock { + return { kind: 'text', text } +} + +const ORIGIN = 'http://127.0.0.1:3080' + +describe('localPathMediaUrl', () => { + it('maps an absolute POSIX path on an HTTP page to the file API', () => { + expect(localPathMediaUrl('http:', ORIGIN, '/tmp/graph.png')) + .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + expect(localPathMediaUrl('https:', 'https://127.0.0.1:3080', '/tmp/graph.png')) + .toBe(`https://127.0.0.1:3080/api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + }) + + it('keeps non-HTTP transports inert', () => { + expect(localPathMediaUrl('file:', 'file:///app', '/tmp/graph.png')).toBeUndefined() + expect(localPathMediaUrl('ws:', ORIGIN, '/tmp/graph.png')).toBeUndefined() + }) + + it('keeps destinations that cannot be Host-served local files inert', () => { + expect(localPathMediaUrl('http:', ORIGIN, '')).toBeUndefined() + expect(localPathMediaUrl('http:', ORIGIN, '//cdn.example.com/x.png')).toBeUndefined() + expect(localPathMediaUrl('http:', ORIGIN, 'relative.png')).toBeUndefined() + expect(localPathMediaUrl('http:', ORIGIN, 'C:\\tmp\\x.png')).toBeUndefined() + }) + + it('encodes the full path including spaces', () => { + expect(localPathMediaUrl('http:', ORIGIN, '/tmp/my graph.png')) + .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/my graph.png')}`) + }) +}) + +describe('AssistantMarkdown local-path images', () => { + it('renders a local image path in closing prose through the same-origin API', () => { + const { container } = render( + , + ) + const image = container.querySelector('img') + expect(image?.getAttribute('alt')).toBe('diagram') + const url = new URL(image?.getAttribute('src') ?? '') + expect(url.pathname).toBe('/api/file') + expect(url.searchParams.get('path')).toBe('/tmp/graph.png') + }) + + it('keeps non-absolute destinations inert', () => { + const { container } = render( + , + ) + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toContain('diagram') + }) +}) 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-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index 4f4374735b..fe479990f4 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -46,8 +46,8 @@ export type CommandUiSpec = { export interface CommandContribution { /** Command name without the leading slash (unique across contributions). */ readonly name: string - /** Menu row description. */ - readonly description: string + /** Resolve the localized menu row description when candidates are requested. */ + readonly description: () => string /** Capability filter, called with a fresh projection per candidate pass. */ available(session: ClientSessionContext): boolean /** The command's UI behavior (this phase: popupSelect only). */ diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 384597a9b2..0a875d467c 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -2,6 +2,12 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'description.compact': '压缩以上对话内容', + 'description.export': '将当前会话内容导出为 ZIP', + 'description.feedback': '发送关于当前会话的反馈', + 'description.goal': '设置或查看长期任务目标', + 'description.permission': '切换权限预设(沙箱模式与审批策略)', + 'description.plan': '进入或退出计划模式', 'search.placeholder': '搜索…', 'search.aria': '筛选选项', 'status.loading': '正在加载选项…', @@ -17,6 +23,12 @@ export type CommandKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'description.compact': 'Compact older conversation history', + 'description.export': 'Download this Session log as a ZIP archive', + 'description.feedback': 'record feedback about this session', + 'description.goal': 'set or view the goal for a long-running task', + 'description.permission': 'Switch the permission preset (sandbox mode + approval policy)', + 'description.plan': 'Enter or leave plan mode', 'search.placeholder': 'Search…', 'search.aria': 'Filter options', 'status.loading': 'Loading options…', diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index ae7d350e46..f14f13f0be 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -26,6 +26,7 @@ import type { import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' import { CommandDirectory } from './directory.ts' +import { en, type CommandKey } from './locales.ts' import { PopupSelectController } from './popup.ts' import type { TokenSegment } from './popup.ts' @@ -58,6 +59,16 @@ interface LiveState { readonly popups: Map> } +/** Locale keys for the canonical first-party Host command descriptions. */ +const HOST_DESCRIPTION_KEYS = new Map([ + ['compact', 'description.compact'], + ['export', 'description.export'], + ['feedback', 'description.feedback'], + ['goal', 'description.goal'], + ['permission', 'description.permission'], + ['plan', 'description.plan'], +]) + /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandUiRuntime extends Service implements CommandUiContract { static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands'] @@ -192,14 +203,18 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const seen = new Set() for (const c of list) { seen.add(c.name) - rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) }) + rows.push({ + name: c.name, + description: this.hostDescription(c), + ...(c.input !== undefined ? { hint: c.input.hint } : {}), + }) } for (const contribution of this.live.contributions.values()) { if (!contribution.available(session)) continue if (seen.has(contribution.name)) { throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`) } - rows.push({ name: contribution.name, description: contribution.description }) + rows.push({ name: contribution.name, description: contribution.description() }) } return rankByName( rows.filter(c => req.position === 'leading' || c.hint === undefined), @@ -207,6 +222,12 @@ export class CommandUiRuntime extends Service implements CommandUiContract { ) } + /** Translate exact built-in Host copy while preserving scoped or third-party descriptors verbatim. */ + private hostDescription(command: CommandDescriptor): string { + const key = HOST_DESCRIPTION_KEYS.get(command.name) + return key !== undefined && command.description === en[key] ? this.t(key) : command.description + } + /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ private dispatch(pick: InputTriggerPick): PickOutcome { const name = pick.candidate.name diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 23ce7ae505..f387ebffc1 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -39,6 +39,7 @@ interface BenchOptions { /** Scripted catalog per list payload; default serves the fixed catalogs by session. */ commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }> execute?: (payload: { sessionId: SessionId; line: string }) => Promise + translate?: (namespace: string, key: string, params?: Record) => string addressed?: SessionId } @@ -98,7 +99,8 @@ async function bench(opts: BenchOptions = {}) { // Deterministic key-echo translator: notice assertions read `key{json}`. ctx.provide('locale', { bind: (ns: string) => (key: string, params?: Record) => - `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`, + opts.translate?.(ns, key, params) + ?? `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`, }) // Real scope tags behind a fake sessions face. const scopes = new Map } }>() @@ -164,7 +166,7 @@ const themeUi = (over: Partial = {}): CommandUiSpec => ({ const themeContribution = (over: Partial = {}): CommandContribution => ({ name: 'theme', - description: 'client popup kind', + description: () => 'client popup kind', available: () => true, ui: themeUi(), ...over, @@ -251,6 +253,35 @@ describe('candidates', () => { expect(names).toEqual(['theme']) }) + it('localizes canonical built-in and contribution descriptions on every candidate request', async () => { + let locale = 'zh' + const commands: CommandDescriptor[] = [ + { name: 'compact', description: 'Compact older conversation history' }, + { name: 'goal', description: 'scoped goal override' }, + { name: 'custom', description: 'plugin-authored copy' }, + ] + const { command, source } = await bench({ + commands: () => Promise.resolve({ commands }), + translate: (namespace, key) => `${locale}:${namespace}:${key}`, + }) + command.register(themeContribution({ description: () => `${locale}:theme` })) + + await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([ + { name: 'compact', description: 'zh:command:description.compact' }, + { name: 'goal', description: 'scoped goal override' }, + { name: 'custom', description: 'plugin-authored copy' }, + { name: 'theme', description: 'zh:theme' }, + ]) + + locale = 'en' + await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([ + { name: 'compact', description: 'en:command:description.compact' }, + { name: 'goal', description: 'scoped goal override' }, + { name: 'custom', description: 'plugin-authored copy' }, + { name: 'theme', description: 'en:theme' }, + ]) + }) + it('a contribution/host name collision fails loud', async () => { const { command, source } = await bench() command.register(themeContribution({ name: 'plan' })) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d93d7d7dc5..b28c9069b7 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: e06270a339a23fa17fb9d51642317fcf2aec6c0f -README.zh.md: 315fd3d7798145df7dbb6f504fe7b89c64827024 +README.md: edfcb83193d4513fba846f673d3572c35e8c0d1e +README.zh.md: f92e4c77ba3d3a2ceb96758e5817e447ba2ec1ac diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e06270a339..edfcb83193 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -31,7 +31,7 @@ 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 @@ -48,7 +48,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. -While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). +Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 315fd3d779..f92e4c77ba 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -31,7 +31,7 @@ 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 @@ -48,7 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 -普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 +Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 ## 临时 composer entry 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/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/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index aa37b85ecf..3025bbe691 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -344,6 +344,8 @@ export const InputBar = memo(function InputBar({ // state keeps plain Send. A continuable child keeps Send primary and // exposes Stop independently. const primaryStops = running && subagent === null && (empty || blocked !== undefined) + // Disabled native buttons may omit mouseleave; their tooltip must close from state. + const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending const interruptible = running && continuable const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') @@ -526,7 +528,7 @@ export const InputBar = memo(function InputBar({ {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} {interruptible && ( - +