diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index ebb20ae0c6..0d9f827360 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.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-30-session-end-seed-log-boundary.md -2026-07-30-session-end-seed-log-boundary.md: 79323c9b987bc220bc634393bc542baea409fbbb -2026-07-30-session-end-seed-log-boundary.zh.md: b51c47ff5667c90e394c42f3ef1084c7ecd6b7ba +2026-07-30-session-end-seed-log-boundary.md: aeec2a36d0b1e498591ef509e2e9164f586ed60c +2026-07-30-session-end-seed-log-boundary.zh.md: ceba46a474c402230dbf215a2d53a41d3c027fc2 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index 79323c9b98..aeec2a36d0 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -48,7 +48,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si Bought: one boundary, written in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary. -Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert the boundary is exported as part of complete canonical-log replay, including when it arrived in a resumed constructor seed, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property. +Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert that capture begins with the current lifecycle's newly appended boundary and excludes the constructor seed, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property. `session/end-seed` joins the on-disk vocabulary. Current v1 requires the validated marker semantics owned by Session; the frozen v0 codec and migration edge own which historical v0 seed layouts remain admissible. The exact inherited cut stays separate from the logical header and is available after a body read. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index b51c47ff56..ceba46a474 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -48,7 +48,7 @@ Status: implemented 买到的:一条边界,在一处写入,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。 -代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的:telemetry 的接管测试断言该边界会作为完整权威日志回放的一部分导出,包括它经由 resume constructor seed 进入时;属性测试套件的回放不变式则是「种子逐字节复现,外加一个仅日志边界」,并把幂等性作为独立属性。 +代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的:telemetry 的接管测试断言捕获从当前生命周期新追加的边界开始,并排除 constructor seed;属性测试套件的回放不变式则是「种子逐字节复现,外加一个仅日志边界」,并把幂等性作为独立属性。 `session/end-seed` 加入了落盘词汇表。当前 v1 要求由 Session 拥有的已校验 marker 语义;冻结的 v0 codec 与迁移边负责哪些历史 v0 seed 布局仍可接受。精确继承 cut 与逻辑 header 分离,并在读取正文后可用。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml index 2c0b6abd41..82633faa9a 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md -2026-08-27-handle-based-session-persistence.md: ced7a78178d3036fd5fa9a09ca49f8c751c3a169 -2026-08-27-handle-based-session-persistence.zh.md: e13b5b0e9e7286411b6d38f3b9dc0e86fa742a3d +2026-08-27-handle-based-session-persistence.md: d3fbbe8137c8ac4a1b6897914db26c010036faa8 +2026-08-27-handle-based-session-persistence.zh.md: f8846f4b54cd6b8bb654cb29bbf49d0ee99ccfa8 diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md index ced7a78178..d3fbbe8137 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md @@ -18,7 +18,7 @@ The previous persistence seam owned far more than storage. A shared coordinator **Visibility and freshness are explicit.** A created session is observable in-process from `create`; physical materialization may be deferred (a pure optimization) until the first append or flush, other processes see only materialized sessions, and a crash before materialization means the session never existed. Once an append or flush resolves, reads started afterwards on the same backend instance observe at least that prefix — the guarantee `message-feedback`'s durable-target check rides on. -**Revision simplifies to a per-instance change token.** Equal tokens may be treated as an unchanged log; ownership churn never changes one. JSONL derives a best-effort token and `sizeBytes` from one `fs.stat`; a backend whose medium can count events cheaply may supply the `eventCount` hint instead. The session-list cold blank probe returns on this metadata (`coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`), restoring the capability removed with the path query. +**Revision simplifies to a per-instance change token.** Equal tokens may be treated as an unchanged log; ownership churn never changes one. JSONL derives a best-effort token and `sizeBytes` from one `fs.stat`; a backend whose medium can count events cheaply may supply the `eventCount` hint instead. Session listing remains a zero-body-read metadata/cache operation: it trusts a cached nonblank projection, keeps a cached blank value only when the current cache identity matches, and otherwise reports `blank: false` as the visible unknown fallback. No cold blank probe or probe-limit configuration remains. ## Alternatives considered @@ -37,6 +37,6 @@ Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one e ## Related - [Session persistence as an abstract service](2026-06-14-session-persistence.md) — the seam this reshapes; its interface list reflects the handle API. -- [Persistence export() and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) — the preparatory removals, including the blank probe this note's metadata restores. +- [Persistence export() and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md) — the preparatory removals, including the retired cold blank probe. - [Retain ignorable external session events](2026-08-30-retain-ignorable-external-session-events.md) — the read-side refusal contract, now shared through `storage-contract` helpers. - [Bounded session-persistence write batching](2026-08-08-bounded-session-persistence-write-batching.md) — the batching semantics the routed write path preserves as internal scheduling policy. diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md index e13b5b0e9e..f8846f4b54 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md @@ -18,7 +18,7 @@ Status: implemented **可见性与新鲜度是显式的。**已创建的会话自 `create` 起即可在进程内被观察到;物理实体化(纯粹的优化)可以推迟到第一次 append 或 flush,其他进程只能看到已实体化的会话,实体化之前崩溃意味着该会话从未存在。一旦某次 append 或 flush 完成,其后在同一后端实例上开始的读取至少能观察到该前缀——这正是 `message-feedback` 持久目标检查所依赖的保证。 -**revision 简化为逐实例变更令牌。**令牌相等可视为日志未变;所有权变动绝不会改变令牌。JSONL 通过一次 `fs.stat` 派生尽力而为的令牌与 `sizeBytes`;存储介质能够廉价统计事件数的后端可以改为提供 `eventCount` 提示。会话列表的冷空白探测回归到这些元数据之上(`coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`),恢复了随路径查询一起移除的能力。 +**revision 简化为逐实例变更令牌。**令牌相等可视为日志未变;所有权变动绝不会改变令牌。JSONL 通过一次 `fs.stat` 派生尽力而为的令牌与 `sizeBytes`;存储介质能够廉价统计事件数的后端可以改为提供 `eventCount` 提示。Session listing 保持零正文读取的 metadata/cache 操作:它信任缓存的非空 projection,只在当前 cache identity 匹配时保留缓存的空值,否则用 `blank: false` 表示可见的未知回退。冷空白探测及其探测限制配置均不再存在。 ## 考虑过的替代方案 @@ -37,6 +37,6 @@ Status: implemented ## 相关 - [作为抽象服务的会话持久化](2026-06-14-session-persistence.zh.md)——本 Note 重塑的 seam;其接口列表已反映句柄 API。 -- [持久化 export() 与预发布读取路径精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)——预备性的移除,包括本 Note 的元数据所恢复的空白探测。 +- [持久化 export() 与预发布读取路径精简](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md)——预备性的移除,包括已退役的冷空白探测。 - [保留可忽略的外部会话事件](2026-08-30-retain-ignorable-external-session-events.zh.md)——读取侧的拒绝约定,现经由 `storage-contract` 辅助函数共享。 - [为会话持久化写入批处理设定上界](2026-08-08-bounded-session-persistence-write-batching.zh.md)——被路由写路径作为内部调度策略保留的批处理语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml index fd26357a60..d018fd81f7 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.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-alpha-historical-unknown-event-refusal.md -2026-08-31-alpha-historical-unknown-event-refusal.md: a052c76e37ff3d4649f3fdd1e172a2f569ade857 -2026-08-31-alpha-historical-unknown-event-refusal.zh.md: ac6fd0631187735e7ddbdda0ca90f12c8417291c +2026-08-31-alpha-historical-unknown-event-refusal.md: 58690e30281c1f5e10f85726c1f1e50fd4664fe9 +2026-08-31-alpha-historical-unknown-event-refusal.zh.md: 73ab2ca47ab3f68b11e71ffec09287253215aa53 diff --git a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md index a052c76e37..58690e3028 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md +++ b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md @@ -12,7 +12,7 @@ Silently copying such an event can leave stale numeric references after a later ## Decision -The alpha v0-to-v1 edge owns a frozen complete released-v0 event and payload inventory. It refuses every unknown historical event type before target staging, including an event marked `ignorable: true`, and refuses unexpected members of known payloads except fields explicitly classified as owner-opaque JSON. The diagnostic names the event type, its sequence number, and the unchanged source generation. +The alpha v0-to-v1 edge owns a frozen complete released-v0 event and payload inventory. It refuses every unknown historical event type before target staging, including an event marked `ignorable: true`, and refuses unexpected members of known payloads except fields explicitly classified as owner-opaque JSON. Merge-extensible nested discriminants remain part of that explicit policy: unknown content-block types, message-source kinds, assistant finish-reason kinds, and turn-ending reason kinds are preserved as owner-opaque JSON, while known arms receive structural validation. The diagnostic names the event type, its sequence number, and the unchanged source generation. The rule applies only while crossing a historical format edge. Ordinary current-format reading retains the established envelope behavior: an unknown required event refuses, while an unknown event carrying `ignorable: true` remains readable. New v1 external events therefore keep the existing equal-version extension seam, but they do not become implicitly migratable by a future format edge. diff --git a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md index ac6fd06311..73ab2ca47a 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -Alpha v0-to-v1 迁移边拥有冻结且完整的已发布 v0 事件与 payload 清单。它在目标 staging 前拒绝每个未知历史事件类型,包括标记了 `ignorable: true` 的事件;除明确分类为 owner 不透明 JSON 的字段外,它也拒绝已知 payload 的意外成员。诊断会点名事件类型、序号和保持不变的源 generation。 +Alpha v0-to-v1 迁移边拥有冻结且完整的已发布 v0 事件与 payload 清单。它在目标 staging 前拒绝每个未知历史事件类型,包括标记了 `ignorable: true` 的事件;除明确分类为 owner 不透明 JSON 的字段外,它也拒绝已知 payload 的意外成员。可合并扩展的嵌套判别字段同样属于这项显式策略:未知 content-block type、message-source kind、assistant finish-reason kind 与 turn-ending reason kind 会作为 owner 不透明 JSON 保留,已知分支则接受结构校验。诊断会点名事件类型、序号和保持不变的源 generation。 该规则只适用于跨越历史格式迁移边。普通当前格式读取保留既有信封行为:未知必需事件被拒绝,带 `ignorable: true` 的未知事件仍可读取。因此新的 v1 外部事件继续使用既有同版本扩展 seam,但不会自动获得未来格式迁移能力。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml index 51c64190c9..1e8129eb01 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.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-live-assistant-stream-frames.md -2026-08-31-live-assistant-stream-frames.md: 8b37f559b500256611302074352db76bb66888bf -2026-08-31-live-assistant-stream-frames.zh.md: e9d7d77e60b0d9c3a8db6e7b9bfcfa0c38f551a5 +2026-08-31-live-assistant-stream-frames.md: 7bf62b84c2adc0bf98e8f6ab00fed917a2064422 +2026-08-31-live-assistant-stream-frames.zh.md: 01e537b4676abb9844f9f668bdb98c12ba48bce7 diff --git a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md index 8b37f559b5..7bf62b84c2 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md +++ b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md @@ -10,7 +10,7 @@ The v2 session log keeps one `assistant/message` or `assistant/attempt` settleme ## Decision -`dsh-agent-loop` emits scoped `agent/assistant-stream` frames for each model attempt. `start`, `chunk`, and `end` carry a branded process-local `LlmAttemptId`; every frame advances one Session-local revision. The start frame captures a safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, chunk timestamps are reused by the compact stream, and `end.index` equals the next chunk position. Stream acquisition and its final cancellation check occur before `start`; a failure there emits no frame. The loop appends the final `assistant/message` or `assistant/attempt` before a committed end frame names that event and seq; an abandoned end names no durable event. Authenticated Session-follow accepts an explicit Web opt-in, opens with a cached active-attempt compact baseline, and carries durable events and cursorless frames in one FIFO. Each follower captures a local arrival ordinal with the opening baseline and drops buffered frames at or before that cut; frame revisions can restart at one with a replacement Agent, so they do not define the opening cut. A settlement arriving after an active opening is owned by that attempt only when its seq follows `startedAfterSeq` and its Turn and Step match; it remains staged until the matching end index, type, and seq arrive, while an earlier retry at the same Turn and Step remains visible. Revision, dense-index, or settlement gaps reopen follow and replace the baseline. The TypeScript and Python SDK protocols do not expose these frames. Durable settlements remain the source of replay and model history; the [v2 stream decision](2026-09-01-v2-embedded-assistant-streams.md) owns their representation. +`dsh-agent-loop` emits scoped `agent/assistant-stream` frames for each model attempt. `start`, `chunk`, and `end` carry a branded `LlmAttemptId` unique within one Agent lifecycle; every frame advances one revision local to that lifecycle. The start frame captures a safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, chunk timestamps are reused by the compact stream, and `end.index` equals the next chunk position. Stream acquisition and its final cancellation check occur before `start`; a failure there emits no frame. Every started attempt emits one terminal end: the loop appends the final `assistant/message` or `assistant/attempt` before a committed end names that event and seq, while assembly or settlement failure emits an abandoned end without a durable target. Authenticated Session-follow accepts an explicit Web opt-in, opens with a cached active-attempt compact baseline, and carries durable events and cursorless frames in one FIFO. Each follower captures a local arrival ordinal with the opening baseline and drops buffered frames at or before that cut; frame revisions can restart at one with a replacement Agent, so they do not define the opening cut. A settlement arriving after an active opening is owned by that attempt only when its seq follows `startedAfterSeq` and its Turn and Step match; it remains staged until the matching end index, type, and seq arrive, while an earlier retry at the same Turn and Step remains visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow and replace the baseline; unknown-attempt frames fall back to the durable settlement. The TypeScript and Python SDK protocols do not expose these frames. Durable settlements remain the source of replay and model history; the [v2 stream decision](2026-09-01-v2-embedded-assistant-streams.md) owns their representation. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md index e9d7d77e60..01e537b467 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md @@ -10,7 +10,7 @@ v2 Session log 通过一个 `assistant/message` 或 `assistant/attempt` settleme ## 决定 -`dsh-agent-loop` 为每次模型 attempt 发出作用域内的 `agent/assistant-stream` frame。`start`、`chunk` 和 `end` 带有带品牌的进程本地 `LlmAttemptId`;每个 frame 都会推进一次 Session 本地 revision。start frame 会把壁钟时间捕获为安全整数 `startedTime`,chunk index 从零开始密集递增,chunk 时间戳会被紧凑 stream 复用,`end.index` 等于下一个 chunk 位置。loop 会先取得 stream 并执行最终取消检查,再发出 `start`;这些步骤失败时不发出任何 frame。loop 会在 committed end frame 命名事件与 seq 前追加最终 `assistant/message` 或 `assistant/attempt`;abandoned end 不命名持久事件。已认证 Session-follow 接受显式 Web opt-in,以缓存的活跃 attempt 紧凑 baseline 打开,并在一个 FIFO 中携带持久事件和无 cursor frame。每个 follower 会随 opening baseline 捕获本地到达序号,并丢弃该 cut 及之前的 buffered frame;replacement Agent 的 frame revision 可以从一重新开始,因此 revision 不定义 opening cut。活跃 opening 之后到达的 settlement 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才属于该 attempt;它会保持暂存,直到匹配的 end index、type 与 seq 到达,而同一 Turn 和 Step 中更早的 retry 仍保持可见。revision、密集 index 或 settlement 缺口会重新打开 follow 并替换 baseline。TypeScript 和 Python SDK 协议不公开这些 frame。持久 settlement 仍是 replay 与模型历史的真源;其表示由 [v2 stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)负责。 +`dsh-agent-loop` 为每次模型 attempt 发出作用域内的 `agent/assistant-stream` frame。`start`、`chunk` 和 `end` 带有在单个 Agent lifecycle 内唯一的 branded `LlmAttemptId`;每个 frame 都会推进一次该 lifecycle 本地 revision。start frame 会把壁钟时间捕获为安全整数 `startedTime`,chunk index 从零开始密集递增,chunk 时间戳会被紧凑 stream 复用,`end.index` 等于下一个 chunk 位置。loop 会先取得 stream 并执行最终取消检查,再发出 `start`;这些步骤失败时不发出任何 frame。每个已开始 attempt 都会发出一个终态 end:loop 会在 committed end 命名事件与 seq 前追加最终 `assistant/message` 或 `assistant/attempt`,而 assembly 或 settlement failure 会发出不命名持久目标的 abandoned end。已认证 Session-follow 接受显式 Web opt-in,以缓存的活跃 attempt 紧凑 baseline 打开,并在一个 FIFO 中携带持久事件和无 cursor frame。每个 follower 会随 opening baseline 捕获本地到达序号,并丢弃该 cut 及之前的 buffered frame;replacement Agent 的 frame revision 可以从一重新开始,因此 revision 不定义 opening cut。活跃 opening 之后到达的 settlement 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才属于该 attempt;它会保持暂存,直到匹配的 end index、type 与 seq 到达,而同一 Turn 和 Step 中更早的 retry 仍保持可见。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow 并替换 baseline;unknown-attempt frame 回退到持久 settlement。TypeScript 和 Python SDK 协议不公开这些 frame。持久 settlement 仍是 replay 与模型历史的真源;其表示由 [v2 stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)负责。 ## 曾考虑的替代方案 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 25084dfebf..eee7bc77f6 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: 5f6a8145dcf67872461c17408d18f5a22d820ba6 -2026-08-31-released-session-format-migrations.zh.md: 0aa872877d82d919f43f4a16f5570f0c655e34df +2026-08-31-released-session-format-migrations.md: d044350b9c09ec67f7e5ac502e28b21418ae1993 +2026-08-31-released-session-format-migrations.zh.md: 4ac6ddd97e6d6154853cd6cf514844f0f6180252 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 5f6a8145dc..d044350b9c 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 @@ -42,7 +42,7 @@ This note supersedes the continue-only persistence rule and the deferred-chain s ## Verification -Release verification ran the committed Session-format corpus gate over 152 versioned persisted-or-projected `session*.jsonl` fixtures under `snapshots/`, `packages/`, and `scripts/snapshots/python-sdk-single-exe/`. Fixture-only omitted envelopes and request-header tokens are materialized before the real static catalog; 150 fixtures reached the current v1 view through current restoration or historical migration. Released-v0 replay inputs remain suffixless, while fresh v1 writer outputs use `session.v1.jsonl` for a parent and `session..v1.jsonl` for children. Record and refresh preserve older generations for every role still produced and remove all generations of a child role that the new run no longer produces. The two exact alpha refusals were `snapshots/session/agent-instructions/session.jsonl`, whose projected compaction checkpoint has no matching start, and `snapshots/web/schedule-catalog/session.jsonl`, whose title source contradicts its citations. The continuing gate discovers the corpus dynamically and fails any refusal outside that closed manifest; separate assembled JSONL tests own exact physical-byte migration. +Release verification runs the committed Session-format corpus gate over every versioned persisted-or-projected `session*.jsonl` fixture under `snapshots/`, `packages/`, and `scripts/snapshots/python-sdk-single-exe/`. Fixture-only omitted envelopes and request-header tokens are materialized before the real static catalog; every fixture reaches the current v1 view through current restoration or historical migration. Released-v0 replay inputs remain suffixless, while fresh v1 writer outputs use `session.v1.jsonl` for a parent and `session..v1.jsonl` for children. Record and refresh preserve every completed generation, including generations of a child role absent from a later run. Malformed historical fixtures are repaired at their source rather than admitted through path-dependent replay policy. The continuing gate discovers the corpus dynamically and fails every restoration refusal; separate assembled JSONL tests own exact physical-byte migration. Handle-integration verification runs the pure format, catalog, persistence-seam, and JSONL provider suites together: 420 tests cover both encodings, immutable publication races, header-only observation, read and write handles, migration refusal, append after migration, cancellation, and crash-tail behavior with per-file 100% statement, branch, function, and line coverage. Repository typecheck and lint, 113 keyless recorded-session replays with two declared skips, and 28 owner-local expected-output cases also pass on the merged master checkpoint. 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 0aa872877d..4ac6ddd97e 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 @@ -42,7 +42,7 @@ JSONL 发布在 POSIX 上使用硬链接创建与目录同步,在 Windows 上 ## 验证 -发布验证针对 `snapshots/`、`packages/` 与 `scripts/snapshots/python-sdk-single-exe/` 下 152 个带版本、来自持久化或投影的 `session*.jsonl` fixture 运行了已提交 Session 格式语料门禁。fixture 专用的缺失信封与 request-header token 会先被实体化,再进入真实静态 catalog;其中 150 个通过当前格式 restore 或历史迁移得到当前 v1 视图。Released-v0 replay 输入保持无后缀,而新鲜 v1 writer 输出对 parent 使用 `session.v1.jsonl`、对 child 使用 `session..v1.jsonl`。Record 与 refresh 会为仍由新运行产生的每个 role 保留旧 generation,并删除新运行不再产生的 child role 的全部 generation。两个精确 alpha 拒绝分别是 `snapshots/session/agent-instructions/session.jsonl`(投影出的 compaction checkpoint 没有匹配 start)与 `snapshots/web/schedule-catalog/session.jsonl`(title 来源与其 citation 矛盾)。持续运行的门禁会动态发现语料,并拒绝封闭 manifest 之外的任何失败;独立组装式 JSONL 测试负责精确物理字节迁移。 +发布验证针对 `snapshots/`、`packages/` 与 `scripts/snapshots/python-sdk-single-exe/` 下每个带版本、来自持久化或投影的 `session*.jsonl` fixture 运行已提交 Session 格式语料门禁。fixture 专用的缺失信封与 request-header token 会先被实体化,再进入真实静态 catalog;每个 fixture 都会通过当前格式 restore 或历史迁移得到当前 v1 视图。Released-v0 replay 输入保持无后缀,而新鲜 v1 writer 输出对 parent 使用 `session.v1.jsonl`、对 child 使用 `session..v1.jsonl`。Record 与 refresh 会保留每个已完成 generation,包括后续运行不再产生的 child role generation。Malformed 历史 fixture 在来源处修复,不通过依赖路径的 replay 策略准入。持续运行的门禁会动态发现语料,并拒绝每个 restore failure;独立组装式 JSONL 测试负责精确物理字节迁移。 句柄集成验证会一起运行纯格式、catalog、持久化 seam 与 JSONL provider 测试套件:420 个测试覆盖两种编码、不可变发布竞态、仅 header 观察、读写句柄、迁移拒绝、迁移后 append、取消与崩溃尾部行为,并达到逐文件 100% statement、branch、function 与 line coverage。仓库 typecheck 与 lint、含两个已声明 skip 的 113 个无密钥 recorded-session replay,以及 28 个 owner-local expected-output case 也都在合并 master 的 checkpoint 上通过。 diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml index b14d9346b1..59132acff3 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md -2026-09-02-projcache-cross-version-read-compat.md: 6cb8454ac2f5e9dfd74144aba76d8aecbed09761 -2026-09-02-projcache-cross-version-read-compat.zh.md: 4c541a3968f56d8edd51ad083b4eb21e3e37b059 +2026-09-02-projcache-cross-version-read-compat.md: b08b651e7c826cd1c22b8e5c563ab594331e3c98 +2026-09-02-projcache-cross-version-read-compat.zh.md: 637bca397e054f3b62160fc10c5809e449ef40e2 diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md index 6cb8454ac2..b08b651e7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md @@ -39,7 +39,7 @@ Declared read compatibility — reads tolerate vouched-for older versions, write 5. **Identity matching is stricter than structural admission**: an absent `formatVersion` never matches a current Session, so predecessor rows cannot seed a projection and refold from the authoritative log. Once the format matches, `identityMatches` normalizes absent lineage to unseeded (`?? false` / `?? 0`): exact for an unseeded session, while a seeded expectation fails the match. Poisoned v5 homes therefore boot safely, but their unbound rows are not exposed as current values. 6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `.json.bak.`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). For this domain it supersedes the reset/destroy recovery path of the [2026-07-28 storage recovery proposal](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md), which stays live for authoritative and whole-medium damage. -7. **A predecessor title is a listing hint, not a fold shortcut**: Session-list startup remains metadata/cache-only and never opens cold log bodies. The log header is authoritative; a lifecycle-matching checkpoint is a durable-prefix witness that may lag but cannot lead the log. `cachedPredecessorTitle` therefore exposes only a predecessor `title` row that still passes the current title unit's `stateVersion` and schema. Both adjacent Session-format edges preserve title text. Other predecessor rows remain hidden, and `hydratePrepared`/`coldSnapshot` retain strict format identity, because normalizers can change values such as `blank` or `lastPromptAt` even when storage is physically consistent. +7. **A predecessor title is a listing hint, not a fold shortcut**: Session-list startup remains metadata/cache-only and never opens cold log bodies. The log header is authoritative; a lifecycle-matching checkpoint is a durable-prefix witness that may lag but cannot lead the log. `cachedPredecessorTitle` therefore exposes only a predecessor `title` row that still passes the current title unit's `stateVersion` and schema. Both adjacent Session-format edges preserve title text. The hint uses `asOfSeq: -1` rather than the stored row sequence because a cardinality-changing log migration can remap that coordinate. Other predecessor rows remain hidden, and `hydratePrepared`/`coldSnapshot` retain strict format identity, because normalizers can change values such as `blank` or `lastPromptAt` even when storage is physically consistent. ### v3-v6 → v7 disposition diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md index 4c541a3968..637bca397e 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md @@ -39,7 +39,7 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;v6 只 5. **identity 匹配比结构准入更严格**:缺失 `formatVersion` 的记录绝不匹配当前 Session,因此前代行不能播种投影,而会从权威日志重新折叠。格式匹配后,`identityMatches` 才把缺失 lineage 归一化为 unseeded(`?? false` / `?? 0`):对 unseeded 会话精确,对 seeded 期望则匹配失败。v5 投毒 home 因而可以安全启动,但其未绑定行不会作为当前值暴露。 6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `.json.bak.`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。对本域而言,该策略取代了 [2026-07-28 存储恢复提案](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md)中 reset/destroy 的恢复途径;该提案对权威介质与整介质损坏仍然有效。 -7. **predecessor title 是列表 hint,而不是 fold shortcut**:Session list 启动保持 metadata/cache-only,绝不打开冷 log body。log header 是权威来源;生命周期匹配的 checkpoint 是 durable prefix witness,可以落后但不能领先日志。因此 `cachedPredecessorTitle` 只公开仍通过当前 title unit `stateVersion` 与 schema 的 predecessor `title` row。两条相邻 Session format edge 都保留 title 文本。其他 predecessor row 继续隐藏,`hydratePrepared`/`coldSnapshot` 仍要求严格格式 identity,因为即使物理存储一致,normalizer 仍可能改变 `blank` 或 `lastPromptAt` 等值。 +7. **predecessor title 是列表 hint,而不是 fold shortcut**:Session list 启动保持 metadata/cache-only,绝不打开冷 log body。log header 是权威来源;生命周期匹配的 checkpoint 是 durable prefix witness,可以落后但不能领先日志。因此 `cachedPredecessorTitle` 只公开仍通过当前 title unit `stateVersion` 与 schema 的 predecessor `title` row。两条相邻 Session format edge 都保留 title 文本。该 hint 使用 `asOfSeq: -1`,而不使用存储 row 的序号,因为改变日志事件数量的迁移会重新映射该坐标。其他 predecessor row 继续隐藏,`hydratePrepared`/`coldSnapshot` 仍要求严格格式 identity,因为即使物理存储一致,normalizer 仍可能改变 `blank` 或 `lastPromptAt` 等值。 ### v3-v6 → v7 处置 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml index 51a7388005..4c597587e5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md -2026-08-13-bounded-cold-blank-verification.md: 8244b93641cfe576cd2f5b0c618ae69fbb215dd5 -2026-08-13-bounded-cold-blank-verification.zh.md: 2c7f392fa8fbd3fed0641fe2b95e717f76cd78fb +2026-08-13-bounded-cold-blank-verification.md: 9aec2a2b7df17c3a41c1f30c4ccef0b5a7a37950 +2026-08-13-bounded-cold-blank-verification.zh.md: a8ffe326ef5a43ab9e17f70ee1bf440f2239c1b1 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md index 8244b93641..9aec2a2b7d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md @@ -14,7 +14,7 @@ The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Sess `dsh-api-session-controller` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. -A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank and are served `blank: false`, keeping the Session visible. The earlier physical-size probe — a `locate()` path plus a `coldBlankProbeMaxBytes` eligibility threshold gating an exact `readFrom(id, 0)` fold — is removed with the seam's path query ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md)); persistence snapshot metadata (`eventCount`/`sizeBytes` on `stat()`/`list()`) is the reintroduction path for exact cold verification. +A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. A cache miss does not prove the current log is blank and is served `blank: false`, keeping the Session visible. The earlier physical-size probe — a `locate()` path plus a `coldBlankProbeMaxBytes` eligibility threshold gating an exact `readFrom(id, 0)` fold — is removed with the seam's path query ([export and pre-release trims](../simplification/2026-08-27-persistence-export-and-pre-release-trims.md)). Persistence snapshot hints do not reintroduce it: listing remains metadata/cache-only and never opens a cold body. `updatedAt` is the later of `createdAt` and `lastPromptAt`. A cache miss or stale checkpoint orders the Session too old rather than promoting it from an unrelated file write. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md index 2c7f392fa8..a8ffe326ef 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md @@ -14,7 +14,7 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N `dsh-api-session-controller` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。 -冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空,因而按 `blank: false` 提供,让 Session 保持可见。早先的物理大小探测——`locate()` 路径加上门控一次精确 `readFrom(id, 0)` 折叠的 `coldBlankProbeMaxBytes` 资格阈值——随该 seam 的路径查询一并移除([导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md));persistence 快照元数据(`stat()`/`list()` 上的 `eventCount`/`sizeBytes`)是重新引入精确冷验证的路径。 +冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。cache miss 无法证明当前日志为空,因而按 `blank: false` 提供,让 Session 保持可见。早先的物理大小探测——`locate()` 路径加上门控一次精确 `readFrom(id, 0)` 折叠的 `coldBlankProbeMaxBytes` 资格阈值——随该 seam 的路径查询一并移除([导出与预发布裁剪](../simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md))。Persistence 快照提示不会重新引入它:listing 保持 metadata/cache-only,绝不打开冷正文。 `updatedAt` 取 `createdAt` 与 `lastPromptAt` 中较晚者。cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 5350e26149..261425248b 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.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-23-session-telemetry-otel-revival.md -2026-07-23-session-telemetry-otel-revival.md: 27e9eb9d073c3d4f726595a16bf4b63293207e22 -2026-07-23-session-telemetry-otel-revival.zh.md: 6928d7086c47460e33660dbf3ced6cea7119f456 +2026-07-23-session-telemetry-otel-revival.md: 1fbcacb128c07ec819517e24cc532d356c5ad63e +2026-07-23-session-telemetry-otel-revival.zh.md: 3d43ab8edc823f8dd4805edbef24a6ecf923f956 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 27e9eb9d07..1fbcacb128 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -12,11 +12,11 @@ Every deployment that wants harness sessions in an observability stack must hand `packages/session/` (formerly `telemetry/`) revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them: -- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `SessionTelemetrySink` (`emit`/`flush?`/`shutdown`), the service-registered `SessionTelemetryBackend` form, and `SessionTelemetryCoordinator` own complete capture: each new Session object replays every canonical event from seq 0, then the per-append firehose deep-copies, redacts, and hands off every event with zero I/O; re-adopting the same object resumes after its module-scope cursor. Buffer-free on-demand capture uses the same one-record-per-event mapping through an optional inclusive boundary. Ledger identity includes `session.id`, `session.format_version`, and `event.seq`; live capture also relays `agent/error` and creates dispose-time `shutdown` records. +- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `SessionTelemetrySink` (`emit`/`flush?`/`shutdown`), the service-registered `SessionTelemetryBackend` form, and `SessionTelemetryCoordinator` own lifecycle-local capture: each new Session object starts immediately before `firstLiveSeq`, then the per-append firehose deep-copies, redacts, and hands off every event with zero I/O; re-adopting the same object resumes after its module-scope cursor. Buffer-free on-demand capture uses the same one-record-per-event mapping through an optional inclusive boundary. Ledger identity includes `session.id`, `session.format_version`, and `event.seq`; live capture also relays `agent/error` and creates dispose-time `shutdown` records. - **The `session-telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. - **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `DISABLED` is the default and constructs no transport; the [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) defines the explicit `FULL` and `FEEDBACK_ONLY` delivery modes, which require `exporter.url`, without moving the redaction or backend boundary. [Buffer-free feedback replay](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix. -The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs. Delivery is best-effort: a crash can lose queued records, while constructing the Session again can replay records already delivered before the crash. Receivers therefore deduplicate ledger rows on `(session.id, session.format_version, event.seq)`. +The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs. Delivery is best-effort: a crash can lose queued records. Backend retry or losing the same live object's module-scope cursor can duplicate lifecycle-local rows, so receivers deduplicate on `(session.id, session.format_version, event.seq)`. ## Alternatives considered @@ -28,10 +28,10 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. -**Start a new Session object at its constructor boundary and skip the seed prefix.** Rejected because this assumes another process or parent identity successfully delivered those records. It loses history when telemetry mounts after the original run, when the SDK queue dies in a crash, and when format migration or crash repair creates the current canonical seed before telemetry sees the restored object. Every new object instead starts without a handoff cursor and replays from seq 0, including a fork's inherited prefix and a resumed or migrated log. Re-adopting the same object still resumes after its cursor, so HMR does not duplicate the settled prefix. Full replay can duplicate previously delivered rows and intentionally repeats inherited events under a child session id; receiver-side deduplication uses `(session.id, session.format_version, event.seq)`, while `session.parent_id` and `session.seed_length` preserve lineage. This replay is opportunistic recovery, not an at-least-once guarantee: records can still be lost if no later Session object is constructed, so a deployment requiring guaranteed backfill needs the deferred outbox. +**Replay the complete constructor seed for every new Session object.** Rejected because a fork's inherited events and a resumed or migrated log belong to another lifecycle and may predate the current sharing act. Replaying them under the new object can re-release already shared data, attribute inherited events to a child Session, and make feedback acknowledgement text understate what is handed off. A new object therefore starts immediately before `firstLiveSeq`: fresh Sessions still begin at seq 0, while seeded Sessions begin with their lifecycle boundary. Re-adopting the same object resumes after its cursor, so HMR does not duplicate the settled lifecycle suffix. This rule does not provide crash backfill; a deployment requiring guaranteed historical delivery needs the deferred durable outbox and an explicit disclosure for that broader scope. **Forwarding the seam's turn-boundary `flush()` hint to the OTel provider's `forceFlush()`.** Shipped in the first revival round, then removed: three distinct silent-loss paths shared the wrapper state — a dispose racing an in-flight flush (the SDK's concurrent-flush guard makes shutdown's internal drain skip), overlapping hints displacing the retained promise, and the provider's fixed 30-second flush timeout rejecting while the processor still drains. Every path exists only because the forwarding made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree; with no `flush()` implemented, the batch processor is the only flusher, its `scheduledDelayMillis` (already deployment-tunable through the `processor` passthrough) governs export cadence, and `shutdown()`'s drain is complete by construction. Reinstate only if a deployment states a turn-boundary latency requirement `scheduledDelayMillis` cannot meet — and then by calling the retained `BatchLogRecordProcessor`'s own `forceFlush()`, never the provider's timeout-wrapped one. ## Consequences -A deployment adds one `cordis.yml` entry with an OTLP endpoint and explicitly selects `FULL` to stream complete canonical events into an OTel-compatible stack or `FEEDBACK_ONLY` to replay a complete canonical-log prefix when feedback is recorded. `DISABLED` is the [default](2026-08-10-telemetry-default-off.md) and constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports each event body exactly as captured — including every assistant chunk and any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `session-telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. New-object replay can duplicate ledger rows, and crash durability remains out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and explicitly selects `FULL` to stream lifecycle-local canonical events into an OTel-compatible stack or `FEEDBACK_ONLY` to replay a lifecycle-local canonical-log prefix when feedback is recorded. `DISABLED` is the [default](2026-08-10-telemetry-default-off.md) and constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports each event body exactly as captured — including every assistant chunk and any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `session-telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Same-object replay after lost cursor state can duplicate lifecycle-local ledger rows, and crash durability remains out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 6928d7086c..3d43ab8edc 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -12,12 +12,12 @@ Status: implemented `packages/session/`(原 `telemetry/`)以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责: -- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`SessionTelemetrySink`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `SessionTelemetryBackend` 与 `SessionTelemetryCoordinator` 共同拥有完整捕获:每个新的 Session 对象会从 seq 0 回放全部权威事件,随后逐 append firehose 以零 I/O 深拷贝、脱敏并交接每个事件;重新收养同一对象时从模块作用域游标之后继续。无缓冲按需捕获使用同样的一事件一记录映射,直到可选的包含式边界。Ledger 身份包含 `session.id`、`session.format_version` 与 `event.seq`;实时捕获还会转发 `agent/error`,并创建 dispose(资源释放)时的 `shutdown` 记录。 +- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`SessionTelemetrySink`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `SessionTelemetryBackend` 与 `SessionTelemetryCoordinator` 共同拥有生命周期本地捕获:每个新的 Session 对象从 `firstLiveSeq` 之前开始,随后逐 append firehose 以零 I/O 深拷贝、脱敏并交接每个事件;重新收养同一对象时从模块作用域游标之后继续。无缓冲按需捕获使用同样的一事件一记录映射,直到可选的包含式边界。Ledger 身份包含 `session.id`、`session.format_version` 与 `event.seq`;实时捕获还会转发 `agent/error`,并创建 dispose(资源释放)时的 `shutdown` 记录。 - **`session-telemetry/record` waterfall(瀑布式事件)** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 - **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`DISABLED` 是默认值,且不构造任何传输;[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.zh.md)定义了需显式启用的 `FULL` 与 `FEEDBACK_ONLY` 投递模式,这两种模式要求 `exporter.url`,且不移动脱敏或后端边界。[无缓冲反馈回放](../simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md)避免在内存中创建会话前缀的第二份副本。 -边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,并经 passthrough 配置。投递是尽力而为:崩溃可能丢失已排队记录,而重新构造 Session 也可能回放崩溃前已经送达的记录。因此,接收端要基于 `(session.id, session.format_version, event.seq)` 对 ledger 行去重。 +边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,并经 passthrough 配置。投递是尽力而为:崩溃可能丢失已排队记录。后端重试或丢失同一 live 对象的模块作用域游标可能重复生命周期本地 row,因此接收端基于 `(session.id, session.format_version, event.seq)` 去重。 ## 考虑过的替代方案 @@ -29,10 +29,10 @@ Status: implemented **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费方。 -**让新的 Session 对象从构造边界开始,并跳过 seed 前缀。** 不予采用,因为这种做法假设另一个进程或父身份已经成功投递那些记录。如果遥测在原始运行之后才挂载、SDK 队列随崩溃消失,或格式迁移与崩溃修复在遥测看到恢复对象前生成当前权威 seed,该假设都会丢失历史。每个新对象改为从没有 handoff 游标的状态开始,并从 seq 0 回放,包括 fork 继承前缀以及恢复或迁移后的日志。重新收养同一对象时仍从游标之后继续,因此 HMR 不会重复稳定前缀。全量回放可能重复此前已投递的行,也会有意在 child Session id 下重复继承事件;接收端基于 `(session.id, session.format_version, event.seq)` 去重,`session.parent_id` 与 `session.seed_length` 则保留谱系。这种回放是机会式恢复,而非 at-least-once 保证:如果之后没有构造新的 Session 对象,记录仍可能丢失,因此要求保证回填的部署需要上文推迟的 outbox。 +**为每个新 Session 对象回放完整 constructor seed。** 不予采用,因为 fork 的继承事件以及 resume 或迁移日志属于其他生命周期,可能早于当前共享动作。在新对象下回放这些内容会再次释放已共享数据、把继承事件归因给 child Session,并使反馈确认文本低估实际交接范围。因此,新对象从 `firstLiveSeq` 之前开始:全新 Session 仍从 seq 0 开始,seeded Session 则从其生命周期边界开始。重新收养同一对象时仍从游标之后继续,因此 HMR 不会重复稳定的生命周期后缀。该规则不提供崩溃回填;要求保证历史投递的部署需要延后的 durable outbox,并对更广范围作出显式披露。 **将 seam 的轮次边界 `flush()` 提示转发到 OTel 提供方的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三条不同的静默丢失路径共用同一份包装层状态——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及提供方固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用提供方那个带超时包装的版本。 ## 后果 -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项,并显式选择 `FULL`,即可把完整权威事件流接入任何 OTel 兼容体系;选择 `FEEDBACK_ONLY` 则会在记录反馈时回放完整的权威日志前缀。`DISABLED` 是[默认值](2026-08-10-telemetry-default-off.zh.md),且不构造上报流水线;删除该配置项仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署会按捕获原样导出每个事件 body,包括每条 assistant chunk,以及文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `session-telemetry/record` 监听器,两份 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。新对象回放可能重复 ledger 行,崩溃持久性则在上述 outbox 决定重新审议前继续不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项,并显式选择 `FULL`,即可把生命周期本地权威事件流接入任何 OTel 兼容体系;选择 `FEEDBACK_ONLY` 则会在记录反馈时回放生命周期本地权威日志前缀。`DISABLED` 是[默认值](2026-08-10-telemetry-default-off.zh.md),且不构造上报流水线;删除该配置项仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署会按捕获原样导出每个事件 body,包括每条 assistant chunk,以及文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `session-telemetry/record` 监听器,两份 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。同一对象丢失游标状态后的回放可能重复生命周期本地 ledger 行,崩溃持久性则在上述 outbox 决定重新审议前继续不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index d86262a56b..73f0b1d9a6 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: 3fa8df405d9705a2bf145f1182e18fc0f7586b25 -2026-07-25-subagent-policy-inheritance.zh.md: 94c19d22db050441a6ce48fb507ccc428dbbe91e +2026-07-25-subagent-policy-inheritance.md: c98a2536bdf736eea20aa5d5a9a5f1952d8d29d1 +2026-07-25-subagent-policy-inheritance.zh.md: 1cbfad43b3c7f6def6ad9df04647e5c624e698ea diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index 3fa8df405d..c98a2536bd 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -12,7 +12,7 @@ Sandbox and approval overrides are per-session log folds. An in-process subagent The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. The sandbox-policy service is optional, and only the explicit session override is copied, never deployment defaults or one-shot grants. The approval policy is not inherited: the same capture pins every child to `'never'` — the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md) supersedes this note's original approval-override inheritance. -Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` after the constructor seed, while `Session.inheritedEventCount` keeps the exact fork-prefix length, so the inherited facts follow fork history without changing its lineage cut. Complete telemetry capture includes the entire announced child log, including both its constructor seed and these unpublished-setup events. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. +Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` after the constructor seed, while `Session.inheritedEventCount` keeps the exact fork-prefix length, so the inherited facts follow fork history without changing its lineage cut. Lifecycle-local telemetry starts at `firstLiveSeq`, so it excludes the constructor seed and includes these unpublished-setup events. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. Ordinary session appends validate the inherited events before publication, and persistence captures the complete unpublished log when the session is announced. Any materialized child log therefore stores the inherited events with its first batch; there is no second policy store, schema field, or query index. The `source: 'delegation'` marker lets approval narration distinguish inheritance from a child-side user switch. @@ -23,7 +23,7 @@ A confined child gets the ordinary denial marker, and an escalation request is r ## Alternatives considered - **Generic `SessionHeader` policy fields** — rejected: they duplicate an event-sourced fact in metadata and require propagation through core session types, persistence backends, query indexes, collision identity, and every policy consumer. Unpublished setup events have the required ordering and reuse the existing durable store. -- **Combining new policy facts with constructor history** — rejected because it would classify child-owned delegation policy as inherited history and blur the lifecycle ordering that makes the child snapshot override a stale fork value. Unpublished setup keeps history and new facts on their existing sides of the construction boundary without another session option; telemetry captures both sides. +- **Combining new policy facts with constructor history** — rejected because it would classify child-owned delegation policy as inherited history and blur the lifecycle ordering that makes the child snapshot override a stale fork value. Unpublished setup keeps history and new facts on their existing sides of the construction boundary without another session option; telemetry captures the child-owned side. - **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already permits log appends before publication. - **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment. - **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index 94c19d22db..1cbfad43b3 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -12,7 +12,7 @@ Status: implemented 委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.zh.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。沙箱策略服务为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。审批策略不继承:同一次捕获会把每个子 agent 钉定为 `'never'`——[审批钉定决策](2026-08-10-subagent-approval-pinned-never.zh.md)取代了本 note 原先的审批覆盖项继承。 -每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已把 `Session.firstLiveSeq` 固定在 constructor seed 之后,而 `Session.inheritedEventCount` 保留精确的 fork 前缀长度,因此继承事实会排在 fork 历史之后,却不改变其谱系 cut。完整遥测捕获会包含已公布 child 的整份日志,其中既有 constructor seed,也有这些未发布设置事件。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 +每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已把 `Session.firstLiveSeq` 固定在 constructor seed 之后,而 `Session.inheritedEventCount` 保留精确的 fork 前缀长度,因此继承事实会排在 fork 历史之后,却不改变其谱系 cut。生命周期本地遥测从 `firstLiveSeq` 开始,因此排除 constructor seed,并包含这些未发布设置事件。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 普通的会话追加会在发布前校验继承事件,持久化层则在会话公布时捕获完整的未发布日志。因此,任何已物化的子 agent 日志都会在首批数据中存下继承事件;不存在第二套策略存储、schema 字段或查询索引。`source: 'delegation'` 标记让审批叙述能够区分继承与子 agent 侧的用户切换。 @@ -23,7 +23,7 @@ Status: implemented ## 考虑过的替代方案 - **通用的 `SessionHeader` 策略字段**:不予采纳。它们会在元数据中复制一项事件溯源事实,并要求贯穿核心会话类型、持久化后端、查询索引、碰撞标识与每个策略消费方进行传播。未发布设置阶段的事件具备所需顺序,并复用现有持久化存储。 -- **将新策略事实与构造历史合并**:不予采纳,因为这会把 child 拥有的委派策略归类为继承历史,并模糊 child 快照压过陈旧 fork 值所依赖的生命周期顺序。未发布设置让历史与新事实留在构造边界各自原有的一侧,无需再增加会话选项;遥测会捕获两侧。 +- **将新策略事实与构造历史合并**:不予采纳,因为这会把 child 拥有的委派策略归类为继承历史,并模糊 child 快照压过陈旧 fork 值所依赖的生命周期顺序。未发布设置让历史与新事实留在构造边界各自原有的一侧,无需再增加会话选项;遥测会捕获 child 自有的一侧。 - **首个提示词监听器**:不予采纳。尽管创建事务已经允许在发布前追加日志,它仍会引入监听器顺序与更晚的时序边界。 - **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会记录任何值,因此其子 agent 跟随当前部署。 - **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index ab67e951f7..ef274595bd 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: ecca36703e3f85f67a68348f06f35deeacc33c2a -2026-08-05-feedback-gated-session-telemetry.zh.md: 0f5f8c89223bdbce8fe5cbe838408b9634b45a68 +2026-08-05-feedback-gated-session-telemetry.md: 1ca8a9d3c5a020d1f0aa790b6db03bedd73728cc +2026-08-05-feedback-gated-session-telemetry.zh.md: 4be80fba3f95b62c91adad651798af3f167a9260 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index ecca36703e..1ca8a9d3c5 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -16,7 +16,7 @@ Session telemetry originally has one mounted behavior: every accepted record ent - `FEEDBACK_ONLY` reads the canonical session log when `feedback/record` is appended and hands over the unreleased prefix through that exact event. Records appended after that boundary remain local until another feedback event. - `DISABLED` is the [default](2026-08-10-telemetry-default-off.md), constructs no exporter, processor, or logger provider, and prints that nothing is shared and the feedback remains local when it observes `feedback/record`. -The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture deep-copies, redacts, and hands every canonical event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log after the same-object handoff cursor through an inclusive boundary, then deep-copies, redacts, and hands over every event in that prefix. A new Session object starts with cursor `-1`, so its first capture includes the complete constructor seed whether the object is fresh, forked, resumed, or restored after migration; re-adopting or recapturing the same object starts after its highest handed-off seq. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. +The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture deep-copies, redacts, and hands every canonical event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log after the same-object handoff cursor through an inclusive boundary, then deep-copies, redacts, and hands over every event in that prefix. A new Session object begins immediately before `firstLiveSeq`, so a fresh object starts at seq 0 while a forked, resumed, or migrated object skips its constructor seed and starts with this lifecycle's boundary; re-adopting or recapturing the same object starts after its highest handed-off seq. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when `session.eventAt(event.seq)` returns that exact `feedback/record` object. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration. @@ -32,4 +32,4 @@ Mode resolution is a closed, fail-before-setup check: an unknown direct-construc ## Consequences -`FULL` hands off every canonical event and replays the complete log of each new Session object as an explicit opt-in. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; direct service calls and non-canonical feedback events upload nothing, and a crash before feedback uploads nothing unless a later Session object is restored and feedback releases its log. The first feedback on a new resumed or migrated object includes its complete current canonical prefix through that feedback; later feedback on the same object captures only the suffix after its cursor. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log, so feedback-only streams carry neither `agent-error` nor `shutdown` records and shutdown absence is not a crash signal. New-object replay and backend retries can duplicate ledger rows; receivers deduplicate on `(session.id, session.format_version, event.seq)`. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. +`FULL` hands off every lifecycle-local canonical event as an explicit opt-in. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; direct service calls and non-canonical feedback events upload nothing, and a crash before feedback uploads nothing. The first feedback on a new resumed or migrated object excludes restored constructor history and includes only this lifecycle's boundary and suffix through that feedback; later feedback on the same object captures only the suffix after its cursor. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log, so feedback-only streams carry neither `agent-error` nor `shutdown` records and shutdown absence is not a crash signal. Same-object replay after lost cursor state and backend retries can duplicate lifecycle-local ledger rows; receivers deduplicate on `(session.id, session.format_version, event.seq)`. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index 0f5f8c8922..4be80fba3f 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -16,7 +16,7 @@ Status: implemented - `FEEDBACK_ONLY` 在追加 `feedback/record` 时读取权威会话日志,并交接截至该事件的未释放前缀。该边界后追加的记录会留在本地,直到另一个反馈事件。 - `DISABLED` 是[默认值](2026-08-10-telemetry-default-off.zh.md),不构造导出器、处理器或日志提供方,并在观察到 `feedback/record` 时输出警告,说明什么都不会共享,且反馈仍留在本地。 -通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上深拷贝、脱敏并向后端交接每个权威事件。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从同一对象 handoff 游标之后读取权威日志,直至包含式边界,然后深拷贝、脱敏并交接该前缀中的每个事件。新 Session 对象以游标 `-1` 开始,因此无论对象是全新、fork、resume 还是迁移后恢复,其首次捕获都会包含完整构造 seed;重新收养或再次捕获同一对象时,则从其已交接的最高 seq 之后开始。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md)说明了按需路径为何使用权威日志而非记录副本。 +通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上深拷贝、脱敏并向后端交接每个权威事件。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从同一对象 handoff 游标之后读取权威日志,直至包含式边界,然后深拷贝、脱敏并交接该前缀中的每个事件。新 Session 对象从 `firstLiveSeq` 之前开始:全新对象从 seq 0 开始,而 fork、resume 或迁移对象跳过 constructor seed,从本生命周期边界开始;重新收养或再次捕获同一对象时,则从其已交接的最高 seq 之后开始。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md)说明了按需路径为何使用权威日志而非记录副本。 模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是 `session.eventAt(event.seq)` 返回完全相同的 `feedback/record` 对象。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。 @@ -32,4 +32,4 @@ Status: implemented ## 后果 -`FULL` 作为显式启用模式交接每个权威事件,并回放每个新 Session 对象的完整日志。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;直接服务调用与非权威反馈事件均不上传任何内容,反馈前发生崩溃时也不上传该前缀,除非后续恢复出新的 Session 对象并由反馈释放其日志。新的 resume 或迁移对象上的首次反馈会包含截至该反馈的完整当前权威前缀;同一对象上的后续反馈只捕获游标之后的后缀。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录,因此仅反馈的流既不携带 `agent-error` 也不携带 `shutdown`,且缺少 shutdown 不是崩溃信号。新对象回放与后端重试可能重复 ledger 行;接收端基于 `(session.id, session.format_version, event.seq)` 去重。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 +`FULL` 作为显式启用模式交接本生命周期的每个权威事件。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;直接服务调用与非权威反馈事件均不上传任何内容,反馈前发生崩溃时也不上传任何内容。新的 resume 或迁移对象上的首次反馈会排除已恢复的 constructor 历史,只包含本生命周期边界及截至该反馈的后缀;同一对象上的后续反馈只捕获游标之后的后缀。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录,因此仅反馈的流既不携带 `agent-error` 也不携带 `shutdown`,且缺少 shutdown 不是崩溃信号。同一对象丢失游标状态后的回放与后端重试可能重复生命周期本地 ledger 行;接收端基于 `(session.id, session.format_version, event.seq)` 去重。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml index 454f05ad34..d16acadd94 100644 --- a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.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-feedback-gated-telemetry-default.md -2026-08-25-feedback-gated-telemetry-default.md: 35d79384094ff26a5d9169da9268c264e73a63d4 -2026-08-25-feedback-gated-telemetry-default.zh.md: 28268d6dda5fd734250080c203482b423a752c8e +2026-08-25-feedback-gated-telemetry-default.md: 520225423f7e512230d047853b2e889dd62bffe7 +2026-08-25-feedback-gated-telemetry-default.zh.md: 1b40c5392440aaf33bcd44c65b5f4d7021684876 diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md index 35d7938409..520225423f 100644 --- a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md @@ -10,7 +10,7 @@ Diagnosing a `/feedback` report needs the session data the report describes. Wit ## Decision -The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`. On a Session object already captured, each feedback uploads the suffix after the last handoff through that exact event; a newly constructed fresh, forked, resumed, or migrated object has no cursor, so its first feedback uploads the complete current canonical prefix from seq 0. The acknowledgement's sharing disclosure states that recording feedback releases the session prefix. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it. +The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`. On a Session object already captured, each feedback uploads the suffix after the last handoff through that exact event. A new object starts at its constructor boundary: a fresh Session begins at seq 0, while a forked, resumed, or migrated Session excludes its constructor seed and begins with this lifecycle's `session/end-seed`. The acknowledgement's sharing disclosure therefore matches the released lifecycle-local prefix. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it. This supersedes the session-backend default of the [default-off decision](2026-08-10-telemetry-default-off.md), accepting the user's explicit feedback action as the release authorization that note required a deployment setting for. That note's hard opt-out and its launcher-feed history remain current, and the [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md index 28268d6dda..1b40c53924 100644 --- a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决定 -共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据。对于已经捕获的 Session 对象,每条反馈会上传从上次交接之后至该事件的后缀;新构造的全新、fork、resume 或迁移对象没有游标,因此其首次反馈会从 seq 0 上传完整当前权威前缀。确认信息中的共享声明会说明记录反馈将释放会话前缀。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。 +共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据。对于已经捕获的 Session 对象,每条反馈会上传从上次交接之后至该事件的后缀。新对象从 constructor boundary 开始:全新 Session 从 seq 0 开始,而 fork、resume 或迁移 Session 排除 constructor seed,从本生命周期的 `session/end-seed` 开始。因此,确认信息中的共享声明与所释放的生命周期本地前缀一致。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。 本决定取代[默认关闭决定](2026-08-10-telemetry-default-off.zh.md)中会话后端的默认值,把用户显式的反馈动作接受为该决定原本要求由部署设置提供的释放授权。该决定的强制关闭开关和 launcher 上报历史仍然有效,端点、批处理节奏和退出排空设置仍由[默认挂载决定](2026-07-31-web-telemetry-default-mount.zh.md)持有。 diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml index 924b17d822..c2abc51eb8 100644 --- a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md -2026-08-06-buffer-free-feedback-telemetry.md: d2b8c5908889bcea672d40e7a58cef373338c362 -2026-08-06-buffer-free-feedback-telemetry.zh.md: 4fc8ff5c69ef2b3f5f066359bbca61ec2719a627 +2026-08-06-buffer-free-feedback-telemetry.md: 9e2822e144d66e0ef3318e80cdc63ad5beba08a3 +2026-08-06-buffer-free-feedback-telemetry.zh.md: cc77c60ada2cfc8df486fb572f27c6c475aca608 diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md index d2b8c59088..9e2822e144 100644 --- a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md +++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md @@ -10,9 +10,9 @@ Feedback-only telemetry must upload the session-log prefix only after recorded f ## Decision -The telemetry coordinator provides `live` and `on-demand` capture. On-demand capture registers no session, flush, or operational-event listeners and retains no copied records. `captureSession(session, throughSeq?)` reads the canonical session log after the same-object handoff cursor through an optional inclusive sequence boundary, deep-copies every event in order, runs the current `session-telemetry/record` waterfall, and hands one record per event to the backend, including each `assistant/message` or `assistant/attempt` with its complete embedded stream. A new Session object has no WeakMap entry, so its logical cursor is `-1` and capture starts at seq 0. +The telemetry coordinator provides `live` and `on-demand` capture. On-demand capture registers no session, flush, or operational-event listeners and retains no copied records. `captureSession(session, throughSeq?)` reads the canonical session log after the same-object handoff cursor through an optional inclusive sequence boundary, deep-copies every event in order, runs the current `session-telemetry/record` waterfall, and hands one record per event to the backend, including each lifecycle-local `assistant/message` or `assistant/attempt` with its complete embedded stream. A new Session object has no WeakMap entry, so its logical cursor is immediately before `firstLiveSeq`: `-1` for a fresh object, or the last constructor-seed sequence for a forked, resumed, or migrated object. -`FEEDBACK_ONLY` invokes that method with the `feedback/record` event's sequence. The append is already committed when `session/event` listeners run, so the replay contains the feedback event and cannot include a later suffix. The object-keyed handoff cursor distinguishes later replays without another pending-record index: repeated feedback on the same object releases only a suffix, while the first feedback on a new resumed or migrated object releases its complete current canonical prefix. +`FEEDBACK_ONLY` invokes that method with the `feedback/record` event's sequence. The append is already committed when `session/event` listeners run, so the replay contains the feedback event and cannot include a later suffix. The object-keyed handoff cursor distinguishes later replays without another pending-record index: repeated feedback on the same object releases only a suffix, while the first feedback on a new resumed or migrated object releases only this lifecycle's boundary and suffix. Because on-demand capture reads only the canonical log, it emits no `agent-error` or `shutdown` operational records. Redaction is evaluated at feedback time rather than append time. The [feedback mode decision](../feature/2026-08-05-feedback-gated-session-telemetry.md) owns the public sharing behavior; this note owns its buffer-free realization. @@ -26,4 +26,4 @@ Because on-demand capture reads only the canonical log, it emits no `agent-error ## Consequences -A no-feedback session consumes no telemetry-owned memory proportional to its event count; the canonical session log remains the only pre-feedback copy. Feedback handling performs cloning and redaction synchronously before the backend's non-blocking enqueue, so its cost scales with the unreleased prefix and includes every chunk event. A redaction-policy change before feedback affects that replay, and a crash before feedback uploads nothing unless a later Session object is restored and feedback releases its log. The first capture of each new object starts at seq 0; later feedback on the same object processes only events beyond its handoff cursor. Receivers deduplicate new-object replay on `(session.id, session.format_version, event.seq)`. +A no-feedback session consumes no telemetry-owned memory proportional to its event count; the canonical session log remains the only pre-feedback copy. Feedback handling performs cloning and redaction synchronously before the backend's non-blocking enqueue, so its cost scales with the unreleased lifecycle-local prefix and includes each embedded Assistant stream in that suffix. A redaction-policy change before feedback affects that replay, and a crash before feedback uploads nothing. The first capture of each new object starts at its constructor boundary; later feedback on the same object processes only events beyond its handoff cursor. Receivers deduplicate repeated lifecycle-local rows on `(session.id, session.format_version, event.seq)`. diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md index 4fc8ff5c69..cc77c60ada 100644 --- a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -遥测 coordinator 提供 `live` 与 `on-demand` capture。按需 capture 不注册 Session、flush 或 operational-event listener,也不保留 record 副本。`captureSession(session, throughSeq?)` 从同一对象 handoff cursor 之后读取规范 Session log,直至可选的包含式序号 boundary,按序 deep-copy 每个 event、运行当前 `session-telemetry/record` waterfall,并为每个 event 向 backend 交接一条 record,其中包括每个带完整嵌入式 stream 的 `assistant/message` 或 `assistant/attempt`。新 Session 对象没有 WeakMap entry,因此逻辑 cursor 为 `-1`,capture 从 seq 0 开始。 +遥测 coordinator 提供 `live` 与 `on-demand` capture。按需 capture 不注册 Session、flush 或 operational-event listener,也不保留 record 副本。`captureSession(session, throughSeq?)` 从同一对象 handoff cursor 之后读取规范 Session log,直至可选的包含式序号 boundary,按序 deep-copy 每个 event、运行当前 `session-telemetry/record` waterfall,并为每个 event 向 backend 交接一条 record,其中包括生命周期本地的每个带完整嵌入式 stream 的 `assistant/message` 或 `assistant/attempt`。新 Session 对象没有 WeakMap entry,因此逻辑 cursor 位于 `firstLiveSeq` 之前:全新对象为 `-1`,fork、resume 或迁移对象则为 constructor seed 的最后一个序号。 -`FEEDBACK_ONLY` 以 `feedback/record` 事件的序列号调用该方法。`session/event` 监听器运行时,追加已经提交,因此回放包含该反馈事件,且无法包含后续后缀。以对象为键的 handoff 游标可区分后续回放,无需另一个待处理记录索引:同一对象上的重复反馈只释放后缀,而新的 resume 或迁移对象上的首次反馈会释放其完整当前权威前缀。 +`FEEDBACK_ONLY` 以 `feedback/record` 事件的序列号调用该方法。`session/event` 监听器运行时,追加已经提交,因此回放包含该反馈事件,且无法包含后续后缀。以对象为键的 handoff 游标可区分后续回放,无需另一个待处理记录索引:同一对象上的重复反馈只释放后缀,而新的 resume 或迁移对象上的首次反馈只释放本生命周期边界及其后缀。 按需捕获只读取权威日志,因此不会发出 `agent-error` 或 `shutdown` 运维记录。脱敏在反馈时而非追加时求值。[反馈模式决策](../feature/2026-08-05-feedback-gated-session-telemetry.zh.md)规定公开的共享行为;本记录规定其无缓冲实现。 @@ -26,4 +26,4 @@ Status: implemented ## 后果 -没有反馈的会话不会消耗随事件数量增长的遥测自有内存;权威会话日志仍是反馈前的唯一副本。反馈处理会在后端非阻塞入队前同步执行深拷贝与脱敏,因此其开销随未释放前缀增长,并包含每条 chunk 事件。反馈前的脱敏策略变更会影响该次回放;反馈前发生崩溃时不会上传任何内容,除非后续恢复出新的 Session 对象并由反馈释放其日志。每个新对象首次捕获时从 seq 0 开始;同一对象上的后续反馈只处理 handoff 游标之后的事件。接收端基于 `(session.id, session.format_version, event.seq)` 对新对象回放去重。 +没有反馈的会话不会消耗随事件数量增长的遥测自有内存;权威会话日志仍是反馈前的唯一副本。反馈处理会在后端非阻塞入队前同步执行深拷贝与脱敏,因此其开销随未释放的生命周期本地前缀增长,并包含该后缀中的每个嵌入式 Assistant stream。反馈前的脱敏策略变更会影响该次回放;反馈前发生崩溃时不会上传任何内容。每个新对象首次捕获时从 constructor boundary 开始;同一对象上的后续反馈只处理 handoff 游标之后的事件。接收端基于 `(session.id, session.format_version, event.seq)` 对重复的生命周期本地行去重。 diff --git a/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.i18n.yaml index f78c98f5a7..fb810485c7 100644 --- a/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md -2026-08-27-persistence-export-and-pre-release-trims.md: ed01023023e9d7817b08e275492b7ec244b893a5 -2026-08-27-persistence-export-and-pre-release-trims.zh.md: 1e31a5836c2761c3d6fb5dbb68b30e8691c1fc69 +2026-08-27-persistence-export-and-pre-release-trims.md: 90b050edeed36ec1c3d2becdcb44cecaea01b308 +2026-08-27-persistence-export-and-pre-release-trims.zh.md: aa05be1ee092d75439e839b51e61eb2a7927d3ee diff --git a/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md index ed01023023..90b050edee 100644 --- a/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md +++ b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.md @@ -16,7 +16,7 @@ The session-persistence seam is moving to a handle-based API with cross-process - `DSH_SESSION_JSONL` no longer exists; shell-env registers no persistence contributor. The variable was only honest with `compression: 'none'` — the default `.jsonl.zstd` artifact is unreadable from bash. - The Claude Code / Codex hook bridges keep `transcript_path` in the wire payload for protocol shape but always send `''` / `null`. Hook scripts could not parse the compressed artifact either. -- The `locate`-based size gate for the session-controller cold blank probe is deleted. The probe itself runs on stat metadata: the [handle-based seam](../architecture/2026-08-27-handle-based-session-persistence.md)'s `stat()`/`list()` snapshots carry optional `eventCount`/`sizeBytes`, and session-controller bounds the probe with `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes`; a cold session past both thresholds, or on a backend offering neither hint, reports `blank: false` (unknown). +- The `locate`-based size gate and the session-controller cold blank probe are deleted. The [handle-based seam](../architecture/2026-08-27-handle-based-session-persistence.md)'s `stat()`/`list()` snapshots may still carry `eventCount`/`sizeBytes`, but listing never opens a body from those hints and has no `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes` configuration. A cold row without a current cache answer reports `blank: false` (unknown). **Legacy event-shape migration is deleted.** Reads validate current v0 records only. Retired event types (`steering/message`, `mode/set`, `request/header-delta`) refuse through the read-side vocabulary gate as `SessionFormatUnsupportedError`. Pre-identity message payloads and the `request/header` `fallback` reason refuse through session validation — surfaced as `SessionPersistenceCorruptionError` on the load/inspect path and as the plain validation error on `readFrom`. Pre-react-loop turn envelopes have no validator: a stale `turn/start.trigger` field and the coarse `aborted`/`disposed` turn-end reasons load unprojected as extension-shaped data, the documented merge-extensible fall-through that the contract test "preserves extension turn/end reasons outside the closed reason set" pins. This consolidates and supersedes the pre-identity-message and pre-react-loop import notes; their record is preserved below. diff --git a/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md index 1e31a5836c..aa05be1ee0 100644 --- a/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-27-persistence-export-and-pre-release-trims.zh.md @@ -16,7 +16,7 @@ Status: implemented - `DSH_SESSION_JSONL` 不复存在;shell-env 不再注册持久化贡献方。该变量只有在 `compression: 'none'` 时才是诚实的——默认的 `.jsonl.zstd` 产物无法从 bash 读取。 - Claude Code/Codex 钩子桥接层为保持协议格式,仍在线上 payload 中保留 `transcript_path`,但始终发送 `''`/`null`。钩子脚本同样无法解析压缩产物。 -- session-controller 冷空白探测中基于 `locate` 的大小门槛被删除。探测本身运行在 stat 元数据之上:[基于句柄的 seam](../architecture/2026-08-27-handle-based-session-persistence.zh.md) 的 `stat()`/`list()` 快照携带可选的 `eventCount`/`sizeBytes`,session-controller 以 `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes` 限定探测;超过两个阈值的冷会话,或位于两种提示都不提供的后端上的冷会话,报告 `blank: false`(未知)。 +- session-controller 中基于 `locate` 的大小门槛和冷空白探测都被删除。[基于句柄的 seam](../architecture/2026-08-27-handle-based-session-persistence.zh.md) 的 `stat()`/`list()` 快照仍可携带 `eventCount`/`sizeBytes`,但 listing 不会根据这些提示打开正文,也没有 `coldBlankProbeMaxEvents`/`coldBlankProbeMaxBytes` 配置。没有当前 cache 答案的冷 row 报告 `blank: false`(未知)。 **删除 legacy 事件形态迁移。**读取只校验当前 v0 记录。已废弃的事件类型(`steering/message`、`mode/set`、`request/header-delta`)经由读取侧词汇门禁以 `SessionFormatUnsupportedError` 拒绝。消息标识机制之前的消息 payload 与 `request/header` 的 `fallback` 原因经由会话校验拒绝——在 load/inspect 路径上表现为 `SessionPersistenceCorruptionError`,在 `readFrom` 上表现为普通校验错误。react-loop 重构之前的轮次 envelope 没有校验器:过时的 `turn/start.trigger` 字段与粗粒度的 `aborted`/`disposed` 轮次结束原因会作为扩展形态数据不经投影地加载,这正是约定测试 "preserves extension turn/end reasons outside the closed reason set" 所钉住的、有文档记载的可合并扩展 fall-through。此举合并并取代了 pre-identity-message 与 pre-react-loop 两份导入 Note;其记录保存在下文。 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index f3914e1245..93eca17657 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md -2026-06-22-subagent-snapshot-replay.md: 2cca3206363a5211f598563085bb0165025d1313 -2026-06-22-subagent-snapshot-replay.zh.md: c6edfa7fa7faba259da0c593cdcc0cb507e0ea0c +2026-06-22-subagent-snapshot-replay.md: 0d5d1fd2c920f8932bdf2a96e3294b90d6160f80 +2026-06-22-subagent-snapshot-replay.zh.md: 130ea1fac90216058026143909e970bf8d5ea2d2 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md index 2cca320636..0d5d1fd2c9 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -25,7 +25,7 @@ Replay is keyed **per calling session**, and the harness harvests **every** sess ### 2. Replay binds live sessions to recorded scripts by first-call order -A nested scenario records more than one role: parent `session[.vN].jsonl`, then one per subagent child as `session.[.vN].jsonl`. V0 omits `.v0`; positive generations use lowercase `.vN`; the harness selects the numerically highest file per role. `dsh-llm-replay` loads that selected set, derives one script per recorded Session, and orders the scripts by role (parent then contiguous children), while persisted discovery still uses header `createdAt` to assign child ordinals. +A nested scenario records more than one role: parent `session[.vN].jsonl`, then one per subagent child as `session.[.vN].jsonl`. V0 omits `.v0`; positive generations use lowercase `.vN`; the harness selects the numerically highest file per role. `dsh-llm-replay` loads that selected set and derives one script per recorded Session. The primary script always binds first; child scripts bind by header `createdAt`, with recorded id breaking timestamp ties. Persisted discovery separately uses `createdAt` to assign child fixture ordinals. Live session ids are freshly random every run and never equal the recorded ones, so a live session cannot bind to a script by id equality. Instead it binds by **first-call order**: the first live session to make any model call claims the first ordered script (the parent — earliest `createdAt`, and necessarily the first to stream, because it must run a turn before it can delegate), the next new live session claims the next script, and so on. Each session then advances its own cursor independently. diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index c6edfa7fa7..130ea1fac9 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -25,7 +25,7 @@ Status: implemented ### 2. 回放按首次调用顺序将活跃会话绑定到录制脚本 -嵌套场景录制多个角色:parent 使用 `session[.vN].jsonl`,每个 subagent child 使用 `session.[.vN].jsonl`。V0 省略 `.v0`,正 generation 使用小写 `.vN`,harness 为每个角色选择数值最高的文件。`dsh-llm-replay` 加载这个选定集合,为每个录制 Session 派生一份脚本,并按角色排序(parent 后接连续 child);持久化发现仍按 header `createdAt` 分配 child ordinal。 +嵌套场景录制多个角色:parent 使用 `session[.vN].jsonl`,每个 subagent child 使用 `session.[.vN].jsonl`。V0 省略 `.v0`,正 generation 使用小写 `.vN`,harness 为每个角色选择数值最高的文件。`dsh-llm-replay` 加载这个选定集合,并为每个录制 Session 派生一份脚本。Primary 脚本始终先绑定;child 脚本按 header `createdAt` 绑定,timestamp 相同时由 recorded id 决胜。持久化发现另外按 `createdAt` 分配 child fixture ordinal。 活跃会话 id 每次运行都是全新随机值,永远不等于录制时的 id,因此活跃会话无法通过 id 相等绑定到脚本。取而代之的是**首次调用顺序**绑定:第一个发起任何模型调用的活跃会话认领第一份有序脚本(即父会话:`createdAt` 最早,且必然最先流式输出,因为它必须先运行一个轮次才能委派),下一个新活跃会话认领下一份脚本,依此类推。此后每个会话独立推进自己的游标。 diff --git a/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts index 5cb06b34bf..852e58453c 100644 --- a/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts @@ -67,8 +67,8 @@ function normalizeGoalLog(content: string, context: NormalizeContext): string { /** Compare one current normalized goal log with its generation-aware committed fixture. */ async function expectGoalLog(actual: string, expectedPath: string): Promise { const expected = await readFile(expectedPath, 'utf8') - expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parseJsonl)) - .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parseJsonl)) + expect(normalizeSessionSnapshots([actual], fixtureContext(actual)).map(parseJsonl)) + .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected)).map(parseJsonl)) } describe('same-session goal snapshot through the ACP automation driver', () => { 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 39c71f8810..08ac6fdee7 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -71,7 +71,7 @@ async function expectSessionSnapshot( ): Promise { const [normalizedActual] = normalizeSessionSnapshots([actual], context) const expected = await readFile(expectedPath, 'utf8') - const [normalizedExpected] = normalizeSessionSnapshots([expected], context, { sourcePaths: [expectedPath] }) + const [normalizedExpected] = normalizeSessionSnapshots([expected], context) expect(parseJsonl(normalizedActual ?? '')).toEqual(parseJsonl(normalizedExpected ?? '')) } 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 257632eb65..9b0dd8496b 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 @@ -32,8 +32,8 @@ async function expectSession(actual: string, expectedPath: string): Promise[] => content.split('\n') .filter(line => line.trim().length > 0) .map(line => JSON.parse(line) as Record) - expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parse)) - .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parse)) + expect(normalizeSessionSnapshots([actual], fixtureContext(actual)).map(parse)) + .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected)).map(parse)) } async function seedInterruptedSession(root: string, cwd: string): Promise { 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 a4750cf839..f8c02f2e4b 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 @@ -37,8 +37,8 @@ async function expectSession(actual: string, expectedPath: string): Promise[] => content.split('\n') .filter(line => line.trim().length > 0) .map(line => JSON.parse(line) as Record) - expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parse)) - .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parse)) + expect(normalizeSessionSnapshots([actual], fixtureContext(actual)).map(parse)) + .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected)).map(parse)) } /** 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 3c8bde8570..113b570107 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 @@ -37,8 +37,8 @@ async function expectSession(actual: string, expectedPath: string): Promise[] => content.split('\n') .filter(line => line.trim().length > 0) .map(line => JSON.parse(line) as Record) - expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parse)) - .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parse)) + expect(normalizeSessionSnapshots([actual], fixtureContext(actual)).map(parse)) + .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected)).map(parse)) } /** Seed a completed parent turn with its read-only policy and current LLM selection. */ 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 8e94b430dc..162cedc466 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 @@ -48,8 +48,8 @@ async function expectSession(actual: string, expectedPath: string): Promise[] => content.split('\n') .filter(line => line.trim().length > 0) .map(line => JSON.parse(line) as Record) - expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parse)) - .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parse)) + expect(normalizeSessionSnapshots([actual], fixtureContext(actual)).map(parse)) + .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected)).map(parse)) } interface SeedBaselineOptions { diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index ba02023143..b9457dd224 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -134,8 +134,8 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through beforeAll(async () => { scaffold = await launchWebScaffold({}) const targetCreatedAt = Date.now() - 60_000 - await seedSession(scaffold, sourceSessionFixture(), SOURCE_SESSION_ID, undefined, undefined, { createdAt: targetCreatedAt - 1 }) - await seedSession(scaffold, targetSessionFixture(), TARGET_SESSION_ID, undefined, undefined, { createdAt: targetCreatedAt }) + await seedSession(scaffold, sourceSessionFixture(), SOURCE_SESSION_ID, undefined, { createdAt: targetCreatedAt - 1 }) + await seedSession(scaffold, targetSessionFixture(), TARGET_SESSION_ID, undefined, { createdAt: targetCreatedAt }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) diff --git a/apps/web/tests/scaffold-generation.spec.ts b/apps/web/tests/scaffold-generation.spec.ts new file mode 100644 index 0000000000..e437dd1d36 --- /dev/null +++ b/apps/web/tests/scaffold-generation.spec.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + assertFixtureInventory, + recordedSessionFixturePath, + selectedSessionFixture, +} from './scaffold.ts' + +const roots: string[] = [] + +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +describe('Web snapshot generation filenames', () => { + it('selects the highest parent and child generations without counting retained inputs twice', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-web-fixture-generations-')) + roots.push(root) + for (const name of [ + 'session.jsonl', + 'session.v2.jsonl', + 'session.1.jsonl', + 'session.1.v1.jsonl', + ]) await writeFile(join(root, name), '') + + await expect(selectedSessionFixture(join(root, 'session.jsonl'))) + .resolves.toBe(join(root, 'session.v2.jsonl')) + await expect(selectedSessionFixture(join(root, 'session.1.jsonl'))) + .resolves.toBe(join(root, 'session.1.v1.jsonl')) + await expect(selectedSessionFixture(join(root, 'replay.override.json'))) + .resolves.toBe(join(root, 'replay.override.json')) + }) + + it('leaves an absent override-only parent fixture unresolved', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-web-fixture-generations-')) + roots.push(root) + + await expect(selectedSessionFixture(join(root, 'session.jsonl'))) + .resolves.toBe(join(root, 'session.jsonl')) + }) + + it('records beside an older generation and preserves the parent or child role', () => { + const fixtures = join('/', 'fixtures') + expect(recordedSessionFixturePath(join(fixtures, 'session.jsonl'), 1)) + .toBe(join(fixtures, 'session.v1.jsonl')) + expect(recordedSessionFixturePath(join(fixtures, 'session.2.jsonl'), 3)) + .toBe(join(fixtures, 'session.2.v3.jsonl')) + expect(recordedSessionFixturePath(join(fixtures, 'session.v1.jsonl'), 1)) + .toBe(join(fixtures, 'session.v1.jsonl')) + expect(() => recordedSessionFixturePath(join(fixtures, 'notes.jsonl'), 1)) + .toThrow('invalid Session fixture path') + }) + + it('treats retained generations as one exact inventory role', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-web-fixture-inventory-')) + roots.push(root) + await writeFile(join(root, 'session.jsonl'), `${JSON.stringify({ + type: 'session', version: 0, id: '{{session:1}}', createdAt: 0, delegationDepth: 0, + })}\n`) + await writeFile(join(root, 'session.v1.jsonl'), `${JSON.stringify({ + type: 'session', version: 1, id: '{{session:1}}', createdAt: 0, delegationDepth: 0, + })}\n`) + await writeFile(join(root, 'ui.expected.md'), 'stable\n') + + await expect(assertFixtureInventory(root, ['session.jsonl', 'ui.expected.md'])) + .resolves.toBeUndefined() + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index dadb8efc72..673fa4eec8 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -70,7 +70,6 @@ import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' import { installLlmReplay, parseSessionLog, - parseSessionLogForReplay, prepareSessionSnapshotFixtureForComparison, } from '@deepseek-ai/dsh-llm-replay' import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format' @@ -719,7 +718,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ( event.type === 'assistant/message' || event.type === 'assistant/attempt' || event.type === 'request/header' || event.type === 'tool/call' @@ -951,11 +950,9 @@ function stableSessionFixture( session: Session, existing: string, workspaceCwd: string, - sourcePath?: string, ): string { const prepared = prepareSessionSnapshotFixtureForComparison( normalizeWebSessionVolatiles(rawSessionLog(session), workspaceCwd), - sourcePath, ) const stabilized = existing === '' ? prepared @@ -981,7 +978,7 @@ async function assertReplaySession( const manifestPath = join(fixtureDir, 'snapshot.yml') const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath) let expectedPath = fixturePath - const userPrompts = fixtureUserPrompts(expected, fixturePath) + const userPrompts = fixtureUserPrompts(expected) const candidates = sessions.filter((session) => { if (session.header.parentSession !== undefined) return false const actual = session.snapshotEvents().flatMap((event) => { @@ -997,7 +994,7 @@ async function assertReplaySession( if (sessionCwd === undefined) throw new Error(`${fixturePath}: replayed session has no cwd`) const actual = rawSessionLog(session) if (mode === 'refresh' && manifest.session === undefined) { - expected = stableSessionFixture(session, expected, sessionCwd, fixturePath) + expected = stableSessionFixture(session, expected, sessionCwd) expectedPath = recordedSessionFixturePath(fixturePath, session.header.version) await writeFile(expectedPath, expected) } @@ -1011,9 +1008,7 @@ async function assertReplaySession( cwd: typeof expectedHeader.cwd === 'string' ? expectedHeader.cwd : '\0no-cwd\0', } expect(normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0], `${fixturePath}: persisted replay`) - .toBe(normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext, { - sourcePaths: [expectedPath], - })[0]) + .toBe(normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0]) if (manifest.header?.pin !== true) return const normalizePrompt = (value: string): string => value @@ -1050,7 +1045,7 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, const target = recordedSessionFixturePath(fixturePath, agent.session.header.version) const existingPath = existsSync(target) ? target : fixturePath const existing = existsSync(existingPath) ? await readFile(existingPath, 'utf8') : '' - await writeFile(target, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd, existingPath)) + await writeFile(target, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd)) } /** @@ -1059,11 +1054,8 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, * @param fixtureText - raw session.jsonl contents. * @returns the recorded user prompt texts. */ -export function fixtureUserPrompts(fixtureText: string, fixturePath?: string): string[] { - const events = fixturePath === undefined - ? parseSessionLog(fixtureText) - : parseSessionLogForReplay(fixtureText, fixturePath) - return events.flatMap((event) => { +export function fixtureUserPrompts(fixtureText: string): string[] { + return parseSessionLog(fixtureText).flatMap((event) => { if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('') return text.length > 0 ? [text] : [] @@ -1119,7 +1111,6 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i /** * Parse a committed web seed fixture through the replay reader. * @param fixtureText - session JSONL fixture contents. - * @param fixturePath - exact source path for the closed replay-only refusal policy. * @returns the current header line, parsed header, and logical events. */ /** Give a migrated fixture stream positive relative timing before its final wall-clock rebase. */ @@ -1139,7 +1130,7 @@ function spreadMigratedSeedStream( }) } -export function parseSeedFixture(fixtureText: string, fixturePath?: string): { +export function parseSeedFixture(fixtureText: string): { headerLine: string header: Record events: SessionEvent[] @@ -1147,7 +1138,7 @@ export function parseSeedFixture(fixtureText: string, fixturePath?: string): { const sourceHeaderLine = fixtureText.split(/\r?\n/).find(line => line.trim().length > 0) if (sourceHeaderLine === undefined) throw new Error('seed fixture has no session header') const sourceHeader = JSON.parse(sourceHeaderLine) as { version?: unknown } - const current = prepareSessionSnapshotFixtureForComparison(fixtureText, fixturePath) + const current = prepareSessionSnapshotFixtureForComparison(fixtureText) const headerLine = current.split(/\r?\n/).find(line => line.trim().length > 0) if (headerLine === undefined) throw new Error('seed fixture has no session header') const header = JSON.parse(headerLine) as Record @@ -1212,13 +1203,11 @@ function seedStreamEnd( /** * Seed a recorded session fixture into the scaffold's persistence root - * through the real Session and JSONL APIs. The source identity is consulted - * only for the two closed replay-only alpha refusals. + * through the real Session and JSONL APIs. * @param scaffold - the target scaffold. * @param fixtureText - raw recorded session.jsonl contents. * @param id - the seeded session id. * @param agentPreset - preset recorded by scenarios that assert resumed composition. - * @param fixturePath - exact source path for replay-only refusal policy. * @returns the seeded id. */ export async function seedSession( @@ -1226,10 +1215,9 @@ export async function seedSession( fixtureText: string, id: string, agentPreset?: string, - fixturePath?: string, options: { readonly createdAt?: number } = {}, ): Promise { - const decoded = parseSeedFixture(realizeSeedFixture(scaffold, fixtureText, id), fixturePath) + const decoded = parseSeedFixture(realizeSeedFixture(scaffold, fixtureText, id)) const events = decoded.events if (events.length === 0) throw new Error('seed fixture has no events') const last = events[events.length - 1]! diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index c0e52d0814..57321914cc 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -608,7 +608,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, }) - await seedSession(scaffold, fixture, CATALOG_SESSION_ID, 'standard', CATALOG_FIXTURE) + await seedSession(scaffold, fixture, CATALOG_SESSION_ID, 'standard') const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) await workspace.attachSession(CATALOG_SESSION_ID) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 29fc7ad158..d10aeb159d 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -103,10 +103,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = beforeAll(async () => { if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot') const selectedBaseFixture = await selectedSessionFixture(BASE_FIXTURE) - const baseFixture = prepareSessionSnapshotFixtureForComparison( - await readFile(selectedBaseFixture, 'utf8'), - selectedBaseFixture, - ) + const baseFixture = prepareSessionSnapshotFixtureForComparison(await readFile(selectedBaseFixture, 'utf8')) sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-')) const childFixturePath = join(sidecarRoot, 'child.jsonl') await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true)) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 9418ca826a..473e8b12b7 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: af8fc0ba039c669483a4e593bc04a40ff1553021 -core.zh.md: 56b601f8a5c481d97f115d2ff14436ec465d0eb8 +core.md: 8b271824dddc9d71a3a160ba1bd034c6cff52ab8 +core.zh.md: 0564d2435a35c7c1f6c8c24851dab497ba967586 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index af8fc0ba03..8b271824dd 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -152,6 +152,42 @@ interface Agent { type AgentStatus = 'idle' | 'running' ``` +```ts type-equiv +/** One process-local live assistant streaming publication. */ +type AssistantStreamFrame = + | { + readonly type: 'start' + readonly attemptId: LlmAttemptId + /** Monotone within one attached Agent lifecycle; replacement restarts at 1. */ + readonly revision: number + /** Safe-integer wall-clock time captured when this attempt started. */ + readonly startedTime: number + readonly turn: number + readonly step: number + } + | { + readonly type: 'chunk' + readonly attemptId: LlmAttemptId + readonly revision: number + /** Dense zero-based position within the attempt. */ + readonly index: number + readonly chunk: StreamChunk + /** Matching durable v1 `assistant/chunk` record for duplicate suppression. */ + readonly legacyChunkSeq: SessionSeq + } + | { + readonly type: 'end' + readonly attemptId: LlmAttemptId + readonly revision: number + /** Number of chunk frames emitted by this attempt. */ + readonly index: number + /** `committed` follows its durable message; `aborted` has no such requirement. */ + readonly outcome: 'committed' | 'aborted' + /** Every durable v1 chunk represented by this attempt. */ + readonly legacyChunkSeqs: readonly SessionSeq[] + } +``` + `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `followup()` returns no handle: its `MessageId` identifies durable inbox insertion, claim, and discard facts, not a later assistant output or turn ending. `whenIdle()` observes the whole agent, so callers may call a receipt-to-idle interval a run only when they explicitly own that interval ([decision](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)). ```ts type-equiv diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 56b601f8a5..0564d2435a 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -156,6 +156,42 @@ interface Agent { type AgentStatus = 'idle' | 'running' ``` +```ts type-equiv +/** One process-local live assistant streaming publication. */ +type AssistantStreamFrame = + | { + readonly type: 'start' + readonly attemptId: LlmAttemptId + /** Monotone within one attached Agent lifecycle; replacement restarts at 1. */ + readonly revision: number + /** Safe-integer wall-clock time captured when this attempt started. */ + readonly startedTime: number + readonly turn: number + readonly step: number + } + | { + readonly type: 'chunk' + readonly attemptId: LlmAttemptId + readonly revision: number + /** Dense zero-based position within the attempt. */ + readonly index: number + readonly chunk: StreamChunk + /** Matching durable v1 `assistant/chunk` record for duplicate suppression. */ + readonly legacyChunkSeq: SessionSeq + } + | { + readonly type: 'end' + readonly attemptId: LlmAttemptId + readonly revision: number + /** Number of chunk frames emitted by this attempt. */ + readonly index: number + /** `committed` follows its durable message; `aborted` has no such requirement. */ + readonly outcome: 'committed' | 'aborted' + /** Every durable v1 chunk represented by this attempt. */ + readonly legacyChunkSeqs: readonly SessionSeq[] + } +``` + `running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。dispose 会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`followup()` 不返回句柄:其 `MessageId` 标识的是持久的 inbox 插入、认领与丢弃事实,而非之后的助手输出或轮次结束。`whenIdle()` 观察的是整个 agent,因此只有当调用方明确拥有从回执到空闲的这段区间时,才能把它称为一次 run([决策](../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md))。 ```ts type-equiv diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 4d011a8c0b..39bbf0016a 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: 8739b6d6f65b0927a4386e6d611570e686b89474 -session-projection.zh.md: 9ef17bcf9383498e4a79e2ecb6c29b9b2f17da83 +session-projection.md: 8568b6df94b1427a341568777c64c82d74176a17 +session-projection.zh.md: 5e50341a6443efde2a3b49b7f5185bc44896efc6 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 8739b6d6f6..8568b6df94 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -149,8 +149,10 @@ cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys * strict {@link cachedSnapshot} / hydration paths continue to reject them. * @param meta - authoritative listed Session header. * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. - * @returns a title-only checkpoint view, or `undefined` when the record is - * current, newer, unrelated, missing, or incompatible with the title unit. + * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined` + * when the record is current, newer, unrelated, missing, or incompatible + * with the title unit. The sentinel avoids reusing a sequence that a + * cardinality-changing Session migration may have remapped. */ cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 9ef17bcf93..5e50341a64 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -149,8 +149,10 @@ cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys * strict {@link cachedSnapshot} / hydration paths continue to reject them. * @param meta - authoritative listed Session header. * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. - * @returns a title-only checkpoint view, or `undefined` when the record is - * current, newer, unrelated, missing, or incompatible with the title unit. + * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined` + * when the record is current, newer, unrelated, missing, or incompatible + * with the title unit. The sentinel avoids reusing a sequence that a + * cardinality-changing Session migration may have remapped. */ cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index ace425e8ac..47bb6dc74e 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: 9852f3783c63771789dad372a5a29de6b20e9d45 -session.zh.md: 47d95f5d71b46fdacca0793b73ab3ff29f7b7436 +session.md: d10c2d9a797eccb4a4cdc7e9ad8d607732dbc877 +session.zh.md: 890d930a6ef979ec1b57eeb2d43ac3eb700a28c4 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 9852f3783c..d10c2d9a79 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -801,7 +801,8 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 47d95f5d71..890d930a6e 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -805,7 +805,8 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 7d776df56a..91b679bed3 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/gateway/README.md -README.md: c9f0cc726373af450da434a774279ac22e4889f6 -README.zh.md: 47699071e6e5d46039271388677e77bccaae5975 +README.md: 019aedfa1b900fd1e52744e91789b22c59d994e9 +README.zh.md: 5cf91d3f6c4f86333201209aabac9b0f9a58d703 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index c9f0cc7263..019aedfa1b 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -47,7 +47,7 @@ Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ o `ctx.remote.$host` reads the fixed Host facts as plain values: `home` (undefined until the first ready frame) and `isLoopback`. It is not a store — no subscription, no generation counter — so a consumer that must react to reconnection listens for `connection/reset` instead of polling it. -`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. A domain may also carry cursorless notifications: they never advance or repair the durable cursor, and notifications received during gap repair publish only after the replacement page commits. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. +`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. A domain may also carry cursorless notifications: they never advance or repair the durable cursor, and notifications received during gap repair publish only after the replacement page commits. If a newer generation supersedes that repair, held notifications from the superseded generation are discarded with its page. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. `ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under bounded jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 47699071e6..5cf91d3f6c 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -47,7 +47,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$host` 以普通值读取固定的 Host 事实:`home`(首个 ready 帧之前为 undefined)与 `isLoopback`。它不是 store——没有订阅、没有代次计数——所以需要响应重连的消费方去监听 `connection/reset`,而不是轮询它。 -`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 +`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。若更新代次取代该修复,旧代次 held notification 会与其 page 一同丢弃。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 `ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按有界且带抖动的指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 7b7b9e0b45..954097ac0d 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: 1b436cadc4992b53b3162b04be1ae256aa44f1d8 -README.zh.md: 38e8b5e9b164204d0ee274590fd7589eb3a33e24 +README.md: 0e023521615b00208c90f20a643ec2b87463d42d +README.zh.md: ddbddff545eec58fa23805c9564cb5f87467b5a6 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 1b436cadc4..0e02352161 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -27,7 +27,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se 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. Create and fork are the only operations that create a new Agent directly. The skill catalog instead 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`, and `append` 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 `startedTime`, `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 publish it while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps reopen follow, and an abandoned end publishes no durable settlement. 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. +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`, and `append` 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 `startedTime`, `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 publish it 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 no durable settlement. 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. 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. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the 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 (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 38e8b5e9b1..ddbddff545 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 每个 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。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedTime`、`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 到达后再发布,而同一步骤中更早的 retry 保持可见。revision、密集 index 或 settlement 缺口会重新打开 follow,abandoned end 不发布持久 settlement。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedTime`、`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 到达后再发布,而同一步骤中更早的 retry 保持可见。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 不发布持久 settlement。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 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` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 diff --git a/packages/api/session-controller/src/client/sessions/assistant-stream.ts b/packages/api/session-controller/src/client/sessions/assistant-stream.ts index b94c40ecfe..77a1dddcbc 100644 --- a/packages/api/session-controller/src/client/sessions/assistant-stream.ts +++ b/packages/api/session-controller/src/client/sessions/assistant-stream.ts @@ -101,6 +101,7 @@ export class ClientAssistantStream { this.durableCursor = Math.max(this.durableCursor, event.seq) this.transientInGap = 0 if (this.attemptForSettlement(event) !== undefined) { + if (this.pending.has(event.seq)) return { type: 'rebaseline' } this.pending.set(event.seq, entry) return undefined } @@ -115,6 +116,7 @@ export class ClientAssistantStream { acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult { switch (frame.type) { case 'start': + if (this.activeAttempt !== undefined || this.pending.size > 0) return { type: 'rebaseline' } this.pending.clear() this.activeAttempt = { attemptId: String(frame.attemptId), @@ -126,9 +128,11 @@ export class ClientAssistantStream { return undefined case 'chunk': { const attempt = this.activeAttempt - if (attempt === undefined - || attempt.attemptId !== String(frame.attemptId) - || frame.index !== attempt.nextIndex) return { type: 'rebaseline' } + // A controller mounted after the Host saw this attempt has no start + // frame to reconstruct. Its durable settlement publishes directly; + // ignore the transient suffix until the next known start. + if (attempt === undefined || attempt.attemptId !== String(frame.attemptId)) return undefined + if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' } attempt.nextIndex += 1 this.transientInGap += 1 return { @@ -151,10 +155,10 @@ export class ClientAssistantStream { } case 'end': { const attempt = this.activeAttempt - this.activeAttempt = undefined if (attempt === undefined || attempt.attemptId !== String(frame.attemptId)) { - return { type: 'rebaseline' } + return undefined } + this.activeAttempt = undefined if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' } if (frame.outcome.kind === 'abandoned') { return this.pending.size === 0 ? undefined : { type: 'rebaseline' } @@ -162,9 +166,7 @@ export class ClientAssistantStream { if (this.publishedSeqs.has(frame.outcome.seq)) return undefined const entry = this.pending.get(frame.outcome.seq) if (entry === undefined - || entry.event.type !== frame.outcome.eventType - || entry.event.data.turn !== attempt.turn - || entry.event.data.step !== attempt.step) { + || entry.event.type !== frame.outcome.eventType) { return { type: 'rebaseline' } } this.pending.delete(frame.outcome.seq) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 4149e92aef..cd24711a83 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -369,7 +369,8 @@ export class SessionController extends TypertRemoteService { * Follow one Session log from its opening or resume cursor. * @param request - durable address and last committed sequence already held by the caller. * @param signal - cancellation owned by the Remote stream carrier. - * @returns a complete opening snapshot followed by gap-free event frames. + * @returns a complete opening snapshot followed by gap-free durable event + * frames and optional cursorless assistant-stream frames. */ @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index b046f0ca91..4aa369c378 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -145,8 +145,6 @@ export class ApiSessionList { private summarizeCold(header: SessionHeader): SessionSummary { const projections = this.projectionsFor(header, undefined) - const raced = this.ctx.sessions.get(header.id) - if (raced !== undefined) return this.summaryFor(raced) const metadata = projections?.values.sessionListMetadata return { sessionId: header.id, diff --git a/packages/api/session-controller/tests/assistant-stream.client.spec.ts b/packages/api/session-controller/tests/assistant-stream.client.spec.ts new file mode 100644 index 0000000000..34cc63a07f --- /dev/null +++ b/packages/api/session-controller/tests/assistant-stream.client.spec.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import { LlmAttemptId, createAssistantMessage } from '@deepseek-ai/dsh-llm' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { + SessionAssistantStreamBaseline, + SessionAssistantStreamFrame, +} from '../src/types.ts' +import { ClientAssistantStream } from '../src/client/sessions/assistant-stream.ts' +import type { SessionLiveEventEntry } from '../src/client/contract/events.ts' + +const ATTEMPT = LlmAttemptId('session:1') + +function entry(event: SessionEvent): SessionLiveEventEntry { + return { type: 'event', event } +} + +function ordinary(seq: number): SessionLiveEventEntry { + return entry({ type: 'turn/start', seq: SessionSeq(seq), time: seq, data: { turn: 1 } }) +} + +function attemptEvent(seq: number, turn = 1, step = 1): SessionLiveEventEntry { + return entry({ + type: 'assistant/attempt', + seq: SessionSeq(seq), + time: seq, + data: { turn, step, stream: [] }, + }) +} + +function messageEvent( + seq: number, + turn = 1, + step = 1, + surfaceOp: 'append' | { readonly op: 'replace'; readonly start: number; readonly end: number } = 'append', +): SessionLiveEventEntry { + return entry({ + type: 'assistant/message', + seq: SessionSeq(seq), + time: seq, + data: { + turn, + step, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'done' }], + source: { provider: 'mock', model: 'mock' }, + }), + stream: [], + }, + surfaceOp: surfaceOp === 'append' + ? surfaceOp + : { ...surfaceOp, start: SessionSeq(surfaceOp.start), end: SessionSeq(surfaceOp.end) }, + }) +} + +function start( + attemptId = ATTEMPT, + startedAfterSeq = -1, +): SessionAssistantStreamFrame { + return { + type: 'start', attemptId, revision: 1, startedTime: 10, + startedAfterSeq: startedAfterSeq === -1 ? -1 : SessionSeq(startedAfterSeq), + turn: 1, step: 1, + } +} + +function chunkFrame( + index: number, + attemptId = ATTEMPT, +): SessionAssistantStreamFrame { + return { + type: 'chunk', attemptId, revision: index + 2, index, time: 20 + index, + chunk: { type: 'text-delta', index: 0, text: `chunk-${index}` }, + } +} + +function end( + index: number, + outcome: Extract['outcome'], + attemptId = ATTEMPT, +): SessionAssistantStreamFrame { + return { type: 'end', attemptId, revision: index + 2, index, outcome } +} + +function baseline(nextIndex = 1): SessionAssistantStreamBaseline { + return { + revision: nextIndex + 1, + activeAttempt: { + attemptId: ATTEMPT, + startedTime: 10, + startedAfterSeq: -1, + turn: 1, + step: 1, + nextIndex, + stream: [ + { type: 'chunk', time: 20, chunk: { type: 'text-delta', index: 0, text: 'first' } }, + { type: 'chunk', time: 21, chunk: { type: 'text-delta', index: 0, text: 'second' } }, + ], + }, + } +} + +function opened(): ClientAssistantStream { + const stream = new ClientAssistantStream() + expect(stream.acceptFrame(start())).toBeUndefined() + return stream +} + +describe('ClientAssistantStream', () => { + it('replaces the durable window and reconstructs only the baseline prefix', () => { + const stream = new ClientAssistantStream() + const durable = ordinary(4) + const visible = stream.replace([durable], baseline(1)) + + expect(visible[0]).toBe(durable) + expect(visible.slice(1)).toEqual([expect.objectContaining({ + type: 'transient', + event: expect.objectContaining({ + type: 'assistant/live-chunk', + seq: 4.5, + time: 20, + }), + })]) + + expect(stream.replace([], baseline(3))).toHaveLength(2) + expect(stream.replace([])).toEqual([]) + }) + + it('passes through durable events not owned by the active attempt', () => { + const stream = new ClientAssistantStream() + stream.acceptFrame(start(ATTEMPT, 1)) + for (const durable of [ + ordinary(1), + messageEvent(2, 1, 1, { op: 'replace', start: 0, end: 0 }), + attemptEvent(0), + attemptEvent(3, 2, 1), + attemptEvent(4, 1, 2), + ]) { + expect(stream.acceptDurable(durable)).toEqual({ type: 'publish', entry: durable }) + } + }) + + it('stages one owned settlement and releases it from the matching end frame', () => { + const stream = opened() + const durable = messageEvent(2) + expect(stream.acceptDurable(durable)).toBeUndefined() + expect(stream.acceptFrame(chunkFrame(0))).toEqual(expect.objectContaining({ type: 'transient' })) + expect(stream.acceptFrame(end(1, { + kind: 'committed', eventType: 'assistant/message', seq: 2, + }))).toEqual({ type: 'settlement', attemptId: String(ATTEMPT), entry: durable }) + }) + + it('rebaselines duplicate durable settlements or starts', () => { + const duplicate = opened() + const durable = attemptEvent(2) + expect(duplicate.acceptDurable(durable)).toBeUndefined() + expect(duplicate.acceptDurable(durable)).toEqual({ type: 'rebaseline' }) + expect(duplicate.acceptFrame(start(LlmAttemptId('session:2')))).toEqual({ type: 'rebaseline' }) + + const clean = new ClientAssistantStream() + expect(clean.acceptFrame(start())).toBeUndefined() + }) + + it('falls back to durable settlement for frames from an unknown attempt', () => { + const stream = new ClientAssistantStream() + const unknown = LlmAttemptId('session:unknown') + expect(stream.acceptFrame(chunkFrame(0, unknown))).toBeUndefined() + expect(stream.acceptFrame(end(0, { kind: 'abandoned' }, unknown))).toBeUndefined() + const durable = attemptEvent(2) + expect(stream.acceptDurable(durable)).toEqual({ type: 'publish', entry: durable }) + + const known = opened() + expect(known.acceptFrame(chunkFrame(0, unknown))).toBeUndefined() + expect(known.acceptFrame(end(0, { kind: 'abandoned' }, unknown))).toBeUndefined() + }) + + it('rebaselines known attempts on chunk or terminal index mismatch', () => { + const chunkMismatch = opened() + expect(chunkMismatch.acceptFrame(chunkFrame(1))).toEqual({ type: 'rebaseline' }) + + const endMismatch = opened() + expect(endMismatch.acceptFrame(end(1, { kind: 'abandoned' }))).toEqual({ type: 'rebaseline' }) + }) + + it('settles abandonment only when no durable settlement remains pending', () => { + const empty = opened() + expect(empty.acceptFrame(end(0, { kind: 'abandoned' }))).toBeUndefined() + + const pending = opened() + expect(pending.acceptDurable(attemptEvent(2))).toBeUndefined() + expect(pending.acceptFrame(end(0, { kind: 'abandoned' }))).toEqual({ type: 'rebaseline' }) + }) + + it('rebaselines committed outcomes without one exact staged settlement', () => { + const published = new ClientAssistantStream() + published.replace([attemptEvent(2)], baseline(0)) + expect(published.acceptFrame(end(0, { + kind: 'committed', eventType: 'assistant/attempt', seq: 2, + }))).toBeUndefined() + + const missing = opened() + expect(missing.acceptFrame(end(0, { + kind: 'committed', eventType: 'assistant/attempt', seq: 2, + }))).toEqual({ type: 'rebaseline' }) + + const wrongType = opened() + expect(wrongType.acceptDurable(messageEvent(2))).toBeUndefined() + expect(wrongType.acceptFrame(end(0, { + kind: 'committed', eventType: 'assistant/attempt', seq: 2, + }))).toEqual({ type: 'rebaseline' }) + }) +}) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index ee5c41f4e2..2872d3b6eb 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -90,7 +90,7 @@ describe('sessions.list cold merge', () => { ctx.provide('sessionProjectionCache', { cachedSnapshot: () => undefined, cachedPredecessorTitle: (meta: SessionHeader) => meta.id === sid('legacy-title') - ? { asOfSeq: 3, values: { title: 'Cached predecessor title' } } + ? { asOfSeq: -1, values: { title: 'Cached predecessor title' } } : undefined, } as never) const remote = createSessionTestRemote(ctx, { @@ -112,7 +112,7 @@ describe('sessions.list cold merge', () => { sessionId: sid('legacy-title'), blank: false, updatedAt: 100, - projections: { asOfSeq: 3, values: { title: 'Cached predecessor title' } }, + projections: { asOfSeq: -1, values: { title: 'Cached predecessor title' } }, }), ]) expect(stat).not.toHaveBeenCalled() @@ -172,51 +172,6 @@ describe('sessions.list cold merge', () => { expect(inspect).not.toHaveBeenCalled() }) - it('prefers a live row attached during cache lookup without folding its seed', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - const meta = header('attached-during-list', 100) - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - }) - const cacheLookup = vi.fn(() => { - const session = ctx.sessions.create(meta.id, { - seed: [ - { type: 'turn/start', seq: SessionSeq(0), time: 200, data: { turn: 1 } }, - { - type: 'user/message', seq: SessionSeq(1), time: 300, - data: createUserMessage({ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }), - surfaceOp: 'append', - }, - ], - meta: { - ...meta.cwd === undefined ? {} : { cwd: meta.cwd }, - createdAt: meta.createdAt, - }, - }) - ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent) - return undefined - }) - ctx.provide('sessionProjectionCache', { - cachedSnapshot: cacheLookup, - cachedPredecessorTitle: () => undefined, - } as never) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - - const response = await remote.list(request({})) - if (!response.ok) throw new Error('list failed') - expect(response.value.items).toEqual([ - expect.objectContaining({ - sessionId: meta.id, - blank: false, - running: true, - updatedAt: 100, - }), - ]) - expect(cacheLookup).toHaveBeenCalledOnce() - }) - }) describe('attached updatedAt tracks human prompts', () => { diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 7541d9a310..95a4edc69e 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: 5e4ab0a741f0b7dae821e52f1a9b0a1faa90ceed -README.zh.md: ef47f897978c8254242075a559f56faa2dceca19 +README.md: c5985f585f92f38cb27ae1385b1052dbce1d9b9d +README.zh.md: 615cdd5a6e3e71ae129d782403f09d0309913929 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 5e4ab0a741..c5985f585f 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -111,7 +111,7 @@ The loop is the production acquisition point for session write handles. When `ct ### Turn and step flow -The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each successful model call appends one `assistant/message` anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered. +The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable `assistant/chunk`, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered. ### Failure and cancellation diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index ef47f89797..615cdd5a6e 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -111,7 +111,7 @@ const handle = await ctx.agents.create({ ### 轮次与步骤流程 -驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次成功的模型调用都恰好追加一个引用其分片 seq 的 `assistant/message` 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 +驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 `assistant/chunk` 之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个引用其分片 seq 的 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 ### 失败与取消 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 180f441f63..5dd560cd99 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -422,57 +422,62 @@ export class ReactLoopAgent implements Agent { } throw error } - const finish = live.finish - if (finish.kind === 'error' || finish.kind === 'aborted') { + try { + const finish = live.finish + if (finish.kind === 'error' || finish.kind === 'aborted') { + live.settle( + 'assistant/attempt', + () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq, + ) + const action = await this.dispatch.waterfall( + 'agent/request-error', { + turn, + step, + provider: request.provider, + failure: finish.failure, + retryPolicy: preparedCall?.retryPolicy, + signal, + }, + () => Promise.resolve(undefined), + ) + signal.throwIfAborted() + if (action?.kind !== 'retry') { + throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) + } + continue + } + + const message = createAssistantMessage({ + content: live.blocks(), + source: { + provider: request.provider, + model: request.model, + ...live.replayState !== undefined ? { replayState: live.replayState } : {}, + }, + }) live.settle( - 'assistant/attempt', - () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq, - ) - const action = await this.dispatch.waterfall( - 'agent/request-error', { + 'assistant/message', + () => this.session.append('assistant/message', { turn, step, - provider: request.provider, - failure: finish.failure, - retryPolicy: preparedCall?.retryPolicy, - signal, - }, - () => Promise.resolve(undefined), + message, + ...live.usage === undefined ? {} : { usage: live.usage }, + stream: live.stream, + }, { surfaceOp: 'append' }).seq, ) - signal.throwIfAborted() - if (action?.kind !== 'retry') { - throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) - } - continue + if (finish.kind === 'max-tokens') return { kind: 'max-tokens' } + + const toolCalls = message.content.filter(block => block.type === 'tool-call') + if (toolCalls.length === 0) return { kind: 'completed' } + const { concluded } = await executeToolCalls( + this.loopCtx, turn, step, toolCalls, signal, + context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), + ) + return concluded ? { kind: 'completed' } : null + } catch (error: unknown) { + if (!live.ended) live.abandon() + throw error } - - const message = createAssistantMessage({ - content: live.blocks(), - source: { - provider: request.provider, - model: request.model, - ...live.replayState !== undefined ? { replayState: live.replayState } : {}, - }, - }) - live.settle( - 'assistant/message', - () => this.session.append('assistant/message', { - turn, - step, - message, - ...live.usage === undefined ? {} : { usage: live.usage }, - stream: live.stream, - }, { surfaceOp: 'append' }).seq, - ) - if (finish.kind === 'max-tokens') return { kind: 'max-tokens' } - - const toolCalls = message.content.filter(block => block.type === 'tool-call') - if (toolCalls.length === 0) return { kind: 'completed' } - const { concluded } = await executeToolCalls( - this.loopCtx, turn, step, toolCalls, signal, - context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), - ) - return concluded ? { kind: 'completed' } : null } } diff --git a/packages/core/agent-loop/src/assistant-stream.ts b/packages/core/agent-loop/src/assistant-stream.ts index fe74cfb153..f897f194bd 100644 --- a/packages/core/agent-loop/src/assistant-stream.ts +++ b/packages/core/agent-loop/src/assistant-stream.ts @@ -19,11 +19,15 @@ export class AssistantStreamAttempt { private readonly accumulator = new AssistantStreamAccumulator() private readonly assembler = new BlockAssembler() private index = 0 - /** Process-local attempt identity. */ + private terminal = false + /** Attempt identity unique within this Agent lifecycle. */ readonly attemptId: LlmAttemptId + /** Whether this started attempt has emitted its terminal frame. */ + get ended(): boolean { return this.terminal } + /** - * @param sessionId - identity embedded only in the process-local attempt id. + * @param sessionId - identity embedded only in the Agent-lifecycle-local attempt id. * @param attempt - attached-Session-local attempt counter. * @param nextRevision - allocates the next emitted frame revision. * @param turn - durable turn owning the request. @@ -80,9 +84,10 @@ export class AssistantStreamAttempt { try { seq = append() } catch (error: unknown) { - this.abandoned() + this.abandon() throw error } + this.terminal = true this.emit({ type: 'end', attemptId: this.attemptId, @@ -93,7 +98,8 @@ export class AssistantStreamAttempt { } /** Publish abandonment when no durable attempt event can be committed. */ - private abandoned(): void { + abandon(): void { + this.terminal = true this.emit({ type: 'end', attemptId: this.attemptId, diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index be7b8c1619..010c5b2cd4 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -130,6 +130,65 @@ describe('agent loop', () => { expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) }) + it('abandons a started live attempt when final block assembly fails', async () => { + const chunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'external-block' } as unknown as StreamChunk, + { type: 'finish', reason: { kind: 'stop' } }, + ] + const ctx = await harness(new MockAdapter([chunks])) + const agent = await ctx.agentLoop.create(SessionId('assistant-assembly-failure'), { + provider: 'mock', + model: 'mock', + }) + const frames: AssistantStreamFrame[] = [] + ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => { + if (subject === agent) frames.push(frame) + }) + + send(agent, 'produce an incomplete external block') + await waitForIdle(ctx, agent) + + expect(frames.map(frame => frame.revision)).toEqual([1, 2, 3, 4]) + expect(frames.at(-1)).toMatchObject({ + type: 'end', + index: 2, + outcome: { kind: 'abandoned' }, + }) + expect(agent.session.snapshotEvents().some(event => ( + event.type === 'assistant/message' || event.type === 'assistant/attempt' + ))).toBe(false) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error' } }, + }) + }) + + it('settles a failed attempt before retrying with a new dense attempt', async () => { + const failed: StreamChunk[] = [ + { type: 'usage', usage: { inputTokens: 1, outputTokens: 0 } }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'retry', code: 'SERVER' } } }, + ] + const ctx = await harness(new MockAdapter([failed, textResponse('recovered')])) + const agent = await ctx.agentLoop.create(SessionId('assistant-retry-attempt'), { + provider: 'mock', + model: 'mock', + }) + const frames: AssistantStreamFrame[] = [] + ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => { + if (subject === agent) frames.push(frame) + }) + ctx.on('agent/request-error', async () => ({ kind: 'retry' as const })) + + send(agent, 'retry once') + await waitForIdle(ctx, agent) + + expect(frames.map(frame => frame.revision)).toEqual(frames.map((_frame, index) => index + 1)) + expect(frames.filter(frame => frame.type === 'start')).toHaveLength(2) + expect(frames.filter(frame => frame.type === 'end').map(frame => ( + frame.outcome.kind === 'committed' ? frame.outcome.eventType : frame.outcome.kind + ))).toEqual(['assistant/attempt', 'assistant/message']) + }) + it('does not emit an end frame when prepared dispatch throws before start', async () => { const ctx = await harness(new MockAdapter([])) const agent = await ctx.agentLoop.create(SessionId('assistant-dispatch-throw'), { diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 21e45482a6..05ca3fb956 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -75,7 +75,7 @@ export type AssistantStreamFrame = | { readonly type: 'start' readonly attemptId: LlmAttemptId - /** Monotone while this Session remains attached to this process. */ + /** Monotone within one attached Agent lifecycle; replacement restarts at 1. */ readonly revision: number /** Safe-integer wall-clock time captured when this attempt started. */ readonly startedTime: number diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a74f9a94e1..8285b0e65b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1435,7 +1435,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: '@Remote({ mode: \'stream\' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', description: 'Follow one Session log from its opening or resume cursor.', parameters: [{ name: 'request', description: 'durable address and last committed sequence already held by the caller.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], - returns: 'a complete opening snapshot followed by gap-free event frames.', + returns: 'a complete opening snapshot followed by gap-free durable event frames and optional cursorless assistant-stream frames.', }, { signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', @@ -1513,7 +1513,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined', description: 'Read only a predecessor checkpoint\'s title as a zero-I/O listing hint.\n\nThe authoritative Session header supplies the lifecycle identity. A cache checkpoint can lag that log but cannot lead it because writes flush the log first, so a matching predecessor title is a genuine (possibly stale) fact from this Session. The registry still requires the current title projection\'s row version and schema. No other predecessor projection is exposed: format normalization can change their current meaning, and the strict cachedSnapshot / hydration paths continue to reject them.', parameters: [{ name: 'meta', description: 'authoritative listed Session header.' }, { name: 'inheritedEventCount', description: 'exact inherited cut completing the lifecycle identity.' }], - returns: 'a title-only checkpoint view, or `undefined` when the record is current, newer, unrelated, missing, or incompatible with the title unit.', + returns: 'a title-only checkpoint view with `asOfSeq: -1`, or `undefined` when the record is current, newer, unrelated, missing, or incompatible with the title unit. The sentinel avoids reusing a sequence that a cardinality-changing Session migration may have remapped.', }, { signature: 'hydratePrepared( session: Session, events: readonly SessionEvent[], ): ProjectionSnapshot', diff --git a/packages/llm/llm/src/brand.ts b/packages/llm/llm/src/brand.ts index b46ae598a0..c919603da4 100644 --- a/packages/llm/llm/src/brand.ts +++ b/packages/llm/llm/src/brand.ts @@ -51,12 +51,12 @@ export function ProviderRequestId(id: string): ProviderRequestId { return brandString(id) } -/** Process-local identity of one loop-owned model streaming attempt. */ +/** Identity of one model streaming attempt, unique within one Agent lifecycle. */ export type LlmAttemptId = Branded<'LlmAttemptId'> /** * Brand one loop-owned streaming attempt identifier. - * @param id - the opaque process-local identifier. + * @param id - the opaque Agent-lifecycle-local identifier. * @returns the same string with the attempt-id brand. */ export function LlmAttemptId(id: string): LlmAttemptId { diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index e3c17174b7..eff3190c30 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md -README.md: f7cfa5c755c0f3e4c68bd20de712ed00bdf97ff4 -README.zh.md: 244df44b339a75d4a6d1e4a2fd4c41a692936f66 +README.md: 283d9e5ca8ede40ddc2f1e57f4c0cd34eecd1018 +README.zh.md: d350a21908bc36c4495e4be8fec0c110b0d36828 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index f7cfa5c755..283d9e5ca8 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -55,7 +55,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u ### What to expect -The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. +The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. ### Failures diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 244df44b33..d350a21908 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -55,7 +55,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 预期行为 -弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。 +弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。 ### 失败 diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts index 24c4157b71..f6106dab78 100644 --- a/packages/session-query/session-log-export/src/archive.ts +++ b/packages/session-query/session-log-export/src/archive.ts @@ -3,9 +3,9 @@ * sessions' logical session logs plus every referenced media object. Each log * is read through a persistence read handle and serialized here as canonical * JSONL — one header line, then one line per validated event — so every - * backend (JSONL, SQLite, future) exports identically. The root log sits at - * `session.jsonl`; each subagent descendant under - * `subagents//session.jsonl`; each image referenced by any included log + * backend (JSONL, SQLite, future) exports identically. The root log uses the + * current generation's canonical `session[.vN].jsonl` name; each subagent + * descendant uses `subagents//session[.vN].jsonl`; each image referenced by any included log * under `media/.` (content-addressed, so one archive never * duplicates a shared image). No manifest is written — every file is * self-describing through its own header line or media type. Before each live @@ -25,6 +25,7 @@ import { Zip, ZipDeflate } from 'fflate' import type { Context } from '@deepseek-ai/cordis' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, SessionStore } from '@deepseek-ai/dsh-session' import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence' @@ -92,8 +93,8 @@ export type SessionLogZipEntry = | { readonly path: string; readonly content: string } | { readonly path: string; readonly data: Uint8Array } -/** The zip base filename for every exported session log. */ -export const SESSION_LOG_FILENAME = 'session.jsonl' +/** The current generation's canonical base filename for every exported session log. */ +export const SESSION_LOG_FILENAME = `session.v${SESSION_FORMAT_VERSION}.jsonl` /** * Serialize one session's logical log as canonical JSONL text: the header diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index a2b239c2a6..9f676d02d1 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -21,6 +21,11 @@ import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser import * as SessionLogExport from '../src/index.ts' const sid = (id: string): SessionId => id as SessionId +const exportLogName = SessionLogExport.SESSION_LOG_FILENAME +const subagentLogName = (id: string): string => `subagents/${id}/${exportLogName}` +const generationLogName = (version: number): string => version === 0 + ? 'session.jsonl' + : `session.v${version}.jsonl` function header(id: string, parentSession?: SessionId): SessionHeader { return { @@ -214,6 +219,10 @@ describe('session export compression config', () => { }) describe('serializeSessionLog', () => { + it('uses the canonical current-generation export filename', () => { + expect(SessionLogExport.SESSION_LOG_FILENAME).toBe(generationLogName(SESSION_FORMAT_VERSION)) + }) + it('writes the physical header line, one line per event, and a trailing newline', () => { const stored = log('session-root') expect(logText(stored)).toBe( @@ -288,8 +297,8 @@ describe('session.export download endpoint', () => { expect(response.headers.get('content-type')).toBe('application/zip') expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files)).toEqual(['session.jsonl']) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(logText(stored)) + expect(Object.keys(files)).toEqual([exportLogName]) + expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(logText(stored)) }) it('preflights root preparation through HEAD without streaming a body', async () => { @@ -335,7 +344,7 @@ describe('session.export download endpoint', () => { const storedBytes = await responseBytes(uncompressed) const compressedBytes = await responseBytes(compressed) expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength) - expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(logText(stored)) + expect(strFromU8(unzipSync(compressedBytes)[exportLogName] as Uint8Array)).toBe(logText(stored)) }) it('includes descendant logs under subagents// when requested', async () => { @@ -353,11 +362,11 @@ describe('session.export download endpoint', () => { expect(response.status).toBe(200) const files = unzipSync(await responseBytes(response)) expect(Object.keys(files).sort()).toEqual([ - 'session.jsonl', - 'subagents/child-a/session.jsonl', - 'subagents/grandchild-a/session.jsonl', + exportLogName, + subagentLogName('child-a'), + subagentLogName('grandchild-a'), ]) - expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) + expect(strFromU8(files[subagentLogName('child-a')] as Uint8Array)) .toBe(logText(child)) }) @@ -390,8 +399,8 @@ describe('session.export download endpoint', () => { ) const files = unzipSync(await responseBytes(response)) expect(flushed).toEqual([sid('session-root'), sid('child-a')]) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(logText(durable['session-root'] as StoredLog)) - expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe(logText(durable['child-a'] as StoredLog)) + expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(logText(durable['session-root'] as StoredLog)) + expect(strFromU8(files[subagentLogName('child-a')] as Uint8Array)).toBe(logText(durable['child-a'] as StoredLog)) }) it('reads a cold log without asking the live-session store to flush', async () => { @@ -409,7 +418,7 @@ describe('session.export download endpoint', () => { ) const files = unzipSync(await responseBytes(response)) expect(flush).not.toHaveBeenCalled() - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(logText(stored)) + expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(logText(stored)) }) it('answers 404 for a session the backend does not store', async () => { @@ -465,7 +474,7 @@ describe('session.export download endpoint', () => { // as U+FFFD and the exported log is silently corrupted. const content = `${'a'.repeat((1 << 16) - 1)}😀tail` const files = await directZipFiles(content) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(content) + expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(content) }) it('splits a long log on a plain code-unit boundary without backoff', async () => { @@ -473,13 +482,13 @@ describe('session.export download endpoint', () => { // round trip must still be byte-identical across the multi-chunk push. const content = 'z'.repeat((1 << 16) + 4096) const files = await directZipFiles(content) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(content) + expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(content) }) it('streams an empty root text as an empty zip entry', async () => { const files = await directZipFiles('') - expect(Object.keys(files)).toEqual(['session.jsonl']) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') + expect(Object.keys(files)).toEqual([exportLogName]) + expect(strFromU8(files[exportLogName] as Uint8Array)).toBe('') }) it('waits for response pull capacity before reading the next archive entry', async () => { @@ -529,10 +538,10 @@ describe('session.export download endpoint', () => { ) const files = unzipSync(await responseBytes(response)) expect(Object.keys(files).sort()).toEqual([ - 'session.jsonl', - 'subagents/child-a/session.jsonl', - 'subagents/child-b/session.jsonl', - 'subagents/shared/session.jsonl', + exportLogName, + subagentLogName('child-a'), + subagentLogName('child-b'), + subagentLogName('shared'), ]) }) @@ -727,7 +736,7 @@ describe('session.export download endpoint', () => { ) expect(response.status).toBe(200) const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl']) + expect(Object.keys(files).sort()).toEqual(['media/img-1.png', exportLogName].sort()) expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) }) @@ -741,7 +750,7 @@ describe('session.export download endpoint', () => { new Request('http://host/api/session.export?sessionId=session-root'), ) const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl']) + expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', exportLogName].sort()) }) it('scans wrapped, inserted, and embedded-stream carriers plus non-object content items', async () => { @@ -777,7 +786,7 @@ describe('session.export download endpoint', () => { 'media/inserted-1.gif', 'media/stream-1.png', 'media/wrapped-1.jpg', - 'session.jsonl', + exportLogName, ]) }) @@ -799,14 +808,14 @@ describe('session.export download endpoint', () => { const without = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root'), ) - expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl']) + expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual([exportLogName]) const withDescendants = await toFetchHandler(api).fetch( new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), ) expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([ 'media/child-img.png', - 'session.jsonl', - 'subagents/child-a/session.jsonl', + exportLogName, + subagentLogName('child-a'), ]) }) diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts index ecd19e99e4..a8905e3892 100644 --- a/packages/session-query/session-log-export/tests/route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/route.host.spec.ts @@ -8,6 +8,7 @@ import { strFromU8, unzipSync } from 'fflate' import { describe, expect, it } from 'vitest' import { Config, + SESSION_LOG_FILENAME, SESSION_LOG_EXPORT_PATH, apply, inject, @@ -68,7 +69,7 @@ describe('Session log export Fetch route', () => { expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('application/zip') const files = unzipSync(new Uint8Array(await response.arrayBuffer())) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toContain('"id":"session-1"') + expect(strFromU8(files[SESSION_LOG_FILENAME] as Uint8Array)).toContain('"id":"session-1"') const head = await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, { method: 'HEAD' }, diff --git a/packages/session/session-format-v0-to-v1/README.i18n.yaml b/packages/session/session-format-v0-to-v1/README.i18n.yaml index c6bae8770a..6283201a79 100644 --- a/packages/session/session-format-v0-to-v1/README.i18n.yaml +++ b/packages/session/session-format-v0-to-v1/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v0-to-v1/README.md -README.md: 965f0b2a8dd99cf44ba5ac16207a1b8502839b64 -README.zh.md: 848236e8042a380b9e8091e0ad0609113d3b79cb +README.md: 2ae4e4a20d07009766df917aca9cd46070fc3639 +README.zh.md: 64137fccae4ee144b6a0a22f95ae4aed18044643 diff --git a/packages/session/session-format-v0-to-v1/README.md b/packages/session/session-format-v0-to-v1/README.md index 965f0b2a8d..2ae4e4a20d 100644 --- a/packages/session/session-format-v0-to-v1/README.md +++ b/packages/session/session-format-v0-to-v1/README.md @@ -38,7 +38,7 @@ const migratedV1 = sessionFormatV0ToV1.migrate(decodedV0) `releasedV0SessionFormatCodec` reads the exact v0 header and physical rows, including packed assistant deltas and range-encoded provenance. `sessionFormatV0ToV1` normalizes and strictly validates a complete detached artifact. `releasedV1SessionFormatCodec` preserves the v1 physical layout without freezing the ordinary event vocabulary; the catalog restores current events against the installed Session package. -The alpha edge refuses every event type outside its frozen inventory, including an unknown event marked `ignorable: true`. It also refuses unexpected payload members. `tool/result.meta` and nested PTC `arguments` remain explicit opaque JSON fields and are preserved without Session-sequence interpretation. +The alpha edge refuses every event type outside its frozen inventory, including an unknown event marked `ignorable: true`. It also refuses unexpected payload members. `tool/result.meta` and nested PTC `arguments` remain explicit opaque JSON fields and are preserved without Session-sequence interpretation. The disposition inventory separately names merge-extensible nested discriminants: unknown content-block `type`, message-source `kind`, assistant finish-reason `kind`, and `turn/end` reason `kind` arms remain owner-opaque JSON while their known arms receive structural validation. The bounded historical normalizers convert `steering/message` to `user/message`, remove `turn/start.trigger`, convert retired `turn/end` reasons, add the current message wrappers and deterministic legacy message ids, and remove the obsolete `request/header.header.messagePrefix` duplicate. Retired `request/header-delta`, `mode/set`, and the `request/header` fallback reason refuse migration. No other event, reference, source, or payload fact may change. diff --git a/packages/session/session-format-v0-to-v1/README.zh.md b/packages/session/session-format-v0-to-v1/README.zh.md index 848236e804..64137fccae 100644 --- a/packages/session/session-format-v0-to-v1/README.zh.md +++ b/packages/session/session-format-v0-to-v1/README.zh.md @@ -38,7 +38,7 @@ const migratedV1 = sessionFormatV0ToV1.migrate(decodedV0) `releasedV0SessionFormatCodec` 读取精确的 v0 标头与物理行,包括打包的 Assistant 增量和范围编码的来源序号。`sessionFormatV0ToV1` 规范化并严格校验一个完整且分离的产物。`releasedV1SessionFormatCodec` 在不冻结普通事件词表的前提下保留 v1 物理布局;目录会根据已安装的 Session 包还原当前事件。 -Alpha 迁移边会拒绝冻结清单之外的所有事件类型,包括带有 `ignorable: true` 标记的未知事件。它也会拒绝意外的 payload 成员。`tool/result.meta` 与嵌套 PTC `arguments` 是显式的不透明 JSON 字段;迁移会原样保留它们,不把其中的数字解释为 Session 序号。 +Alpha 迁移边会拒绝冻结清单之外的所有事件类型,包括带有 `ignorable: true` 标记的未知事件。它也会拒绝意外的 payload 成员。`tool/result.meta` 与嵌套 PTC `arguments` 是显式的不透明 JSON 字段;迁移会原样保留它们,不把其中的数字解释为 Session 序号。处置清单会单独指明可合并扩展的嵌套判别字段:未知 content-block `type`、message-source `kind`、assistant finish-reason `kind` 与 `turn/end` reason `kind` 分支保持 owner-opaque JSON,已知分支则接受结构校验。 有限的历史规范化会把 `steering/message` 转换为 `user/message`、移除 `turn/start.trigger`、转换已停用的 `turn/end` reason、添加当前消息包装层与确定性的旧消息 id,并移除已停用且重复的 `request/header.header.messagePrefix`。已停用的 `request/header-delta`、`mode/set` 和 `request/header` fallback reason 会使迁移失败。除此之外,任何事件、引用、来源或 payload 事实都不得改变。 diff --git a/packages/session/session-format-v0-to-v1/src/codec.ts b/packages/session/session-format-v0-to-v1/src/codec.ts index 4d075659ae..13e09b91c4 100644 --- a/packages/session/session-format-v0-to-v1/src/codec.ts +++ b/packages/session/session-format-v0-to-v1/src/codec.ts @@ -341,7 +341,7 @@ function packChunkRuns(events: readonly SessionFormatEvent[]): readonly (Session } function classifyChunk(event: SessionFormatEvent): ChunkKind | undefined { - if (event.type !== 'assistant/chunk') return undefined + if (event.type !== 'assistant/chunk' || !hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined const data = event.data if (!isSessionFormatJsonObject(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined const chunk = data['chunk'] diff --git a/packages/session/session-format-v0-to-v1/src/dispositions.ts b/packages/session/session-format-v0-to-v1/src/dispositions.ts index 47ef9070ba..77e39bd2a8 100644 --- a/packages/session/session-format-v0-to-v1/src/dispositions.ts +++ b/packages/session/session-format-v0-to-v1/src/dispositions.ts @@ -4,6 +4,8 @@ export interface ReleasedV0PayloadDisposition { readonly optional: readonly string[] /** JSON members whose nested representation is intentionally owner-opaque. */ readonly opaque: readonly string[] + /** Nested discriminant paths whose unknown arms remain owner-opaque JSON. */ + readonly extensionArms: readonly string[] } /** @@ -17,11 +19,13 @@ export function defineReleasedPayloadDisposition( required: readonly string[], optional: readonly string[] = [], opaque: readonly string[] = [], + extensionArms: readonly string[] = [], ): ReleasedV0PayloadDisposition { return Object.freeze({ required: Object.freeze([...required]), optional: Object.freeze([...optional]), opaque: Object.freeze([...opaque]), + extensionArms: Object.freeze([...extensionArms]), }) } @@ -29,17 +33,34 @@ const disposition = defineReleasedPayloadDisposition /** * Frozen released-v0 event and payload-member inventory. - * Every listed member is preserved by the identity edge; members in `opaque` - * remain lossless JSON but receive no nested Session-sequence interpretation. + * Every listed member is preserved by the identity edge. Members in `opaque` + * remain lossless JSON without nested Session-sequence interpretation. Paths + * in `extensionArms` validate known variants and preserve unknown variants as + * owner-opaque JSON, matching the merge-extensible runtime vocabulary. */ export const RELEASED_V0_EVENT_DISPOSITIONS: Readonly> = Object.freeze({ 'agent-preset/selected': disposition(['agentPreset']), - 'agent/inbox/spliced': disposition(['target', 'start', 'inserted'], ['removedCount', 'outcome']), + 'agent/inbox/spliced': disposition( + ['target', 'start', 'inserted'], + ['removedCount', 'outcome'], + [], + ['inserted[].content[].type', 'inserted[].source.kind'], + ), 'approval/asked': disposition(['id', 'toolName'], ['callId', 'reason']), 'approval/decided': disposition(['id', 'outcome']), 'approval/policy': disposition(['policy'], ['source']), - 'assistant/chunk': disposition(['turn', 'step', 'chunk']), - 'assistant/message': disposition(['turn', 'step', 'message'], ['usage', 'interrupted']), + 'assistant/chunk': disposition( + ['turn', 'step', 'chunk'], + [], + [], + ['chunk.blockType', 'chunk.block.type', 'chunk.reason.kind'], + ), + 'assistant/message': disposition( + ['turn', 'step', 'message'], + ['usage', 'interrupted'], + [], + ['message.content[].type'], + ), 'command/done': disposition(['commandId', 'kind'], ['text', 'sourceEventSeq']), 'command/run': disposition(['commandId', 'name', 'source'], ['args']), 'compaction/end': disposition(['compactionId', 'turn'], ['sourceCommandId', 'error']), @@ -48,6 +69,8 @@ export const RELEASED_V0_EVENT_DISPOSITIONS: Readonly { ] const far = { ...v0, events: farTimes } as unknown as SessionFormatArtifact expect(releasedV0SessionFormatCodec.encodeArtifact(far, { packChunks: true }).rows).toHaveLength(3) + + const ignorable = [ + { ...chunk(0, 'text-delta', 'a'), ignorable: true }, + { ...chunk(1, 'text-delta', 'b'), ignorable: true }, + { ...chunk(2, 'text-delta', 'c'), ignorable: true }, + ] as SessionFormatEvent[] + const ignorableV0 = { ...v0, events: ignorable } as unknown as SessionFormatArtifact + expect(releasedV0SessionFormatCodec.encodeArtifact(ignorableV0, { packChunks: true }).rows) + .toEqual(ignorable) }) }) diff --git a/packages/session/session-format-v0-to-v1/tests/relationships.spec.ts b/packages/session/session-format-v0-to-v1/tests/relationships.spec.ts index 1eb74daa41..235acd1caf 100644 --- a/packages/session/session-format-v0-to-v1/tests/relationships.spec.ts +++ b/packages/session/session-format-v0-to-v1/tests/relationships.spec.ts @@ -112,6 +112,33 @@ describe('released v1 whole-artifact relationships', () => { expect(decode(rows).events).toEqual(rows) }) + it('keeps the latest request provider across later steps and turns', () => { + const rows = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { + type: 'request/header', seq: 2, time: 3, + data: { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' }, + }, + { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: 5, time: 6, data: { turn: 2 } }, + { type: 'step/start', seq: 6, time: 7, data: { turn: 2, step: 1 } }, + { + type: 'llm/retry', seq: 7, time: 8, + data: { + retryId: 'retry', turn: 2, step: 1, provider: 'mock', mode: 'normal', policyKey: 'default', + retry: 1, maxRetries: 2, delayMs: 1, failure: { message: 'retry', code: 'SERVER' }, + }, + }, + { type: 'llm/retry-started', seq: 8, time: 9, data: { retryId: 'retry', turn: 2, step: 1, retry: 1 } }, + { type: 'step/end', seq: 9, time: 10, data: { turn: 2, step: 1 } }, + { type: 'turn/end', seq: 10, time: 11, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + + expect(decode(rows).events).toEqual(rows) + }) + it('preserves merge-extensible nested union variants and ignorable current events', () => { const rows = [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, diff --git a/packages/session/session-format-v0-to-v1/tests/validation.spec.ts b/packages/session/session-format-v0-to-v1/tests/validation.spec.ts index 81145e57d9..b4d854e584 100644 --- a/packages/session/session-format-v0-to-v1/tests/validation.spec.ts +++ b/packages/session/session-format-v0-to-v1/tests/validation.spec.ts @@ -290,6 +290,15 @@ describe('released event and payload inventory', () => { } }) + it('publishes every merge-extensible nested arm as owner-opaque policy', () => { + expect(RELEASED_V0_EVENT_DISPOSITIONS['user/message']?.extensionArms) + .toEqual(['content[].type', 'source.kind']) + expect(RELEASED_V0_EVENT_DISPOSITIONS['assistant/chunk']?.extensionArms) + .toContain('chunk.reason.kind') + expect(RELEASED_V0_EVENT_DISPOSITIONS['turn/end']?.extensionArms) + .toEqual(['reason.kind']) + }) + it('refuses unknown v0 events even when the envelope marks them ignorable', () => { const row = { type: 'plugin/unknown', seq: 0, time: 1, data: {}, ignorable: true } expect(() => releasedV0SessionFormatCodec.decodeArtifact(v0Header, [row])) diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 3e15be81b6..7cdc1b0df0 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -427,10 +427,9 @@ export class SessionLogScanner { /** * Create an event scanner from exactly one newline-terminated header record. * @param headerRecord - the complete first JSONL record, including its newline. - * @param storage - already-decoded current metadata from the same header bytes. */ - constructor(headerRecord: Buffer, storage?: SessionStorageMetadata) { - const parsed = storage ?? parseHeaderRecord(headerRecord) + constructor(headerRecord: Buffer) { + const parsed = parseHeaderRecord(headerRecord) this.meta = parsed.meta this.inputBytes = headerRecord.length this.committedBytes = headerRecord.length diff --git a/packages/session/session-persistence-jsonl/src/generation.ts b/packages/session/session-persistence-jsonl/src/generation.ts index c1f3e14040..07eb7bf380 100644 --- a/packages/session/session-persistence-jsonl/src/generation.ts +++ b/packages/session/session-persistence-jsonl/src/generation.ts @@ -496,7 +496,7 @@ async function writeSyncedTemp( internals: JsonlGenerationInternals, ): Promise { for (;;) { - const path = join(dirname(currentPath), `session.migration.${internals.randomToken()}.tmp${suffix}`) + const path = join(dirname(currentPath), `session.migration.${internals.randomToken()}${suffix}.tmp`) let handle: FileHandle try { handle = await internals.fs.open(path, 'wx', 0o600) @@ -611,26 +611,7 @@ async function publishCurrentExclusive( /* v8 ignore next -- the filesystem error is already complete. */ throw error } - try { - await syncDirectory(dirname(currentPath), internals) - } catch (publicationFailure: unknown) { - const failures: unknown[] = [publicationFailure] - try { - await internals.fs.rm(currentPath) - } catch (rollbackFailure: unknown) { - failures.push(rollbackFailure) - } - try { - await syncDirectory(dirname(currentPath), internals) - } catch (rollbackFailure: unknown) { - failures.push(rollbackFailure) - } - if (failures.length === 1) throw publicationFailure - throw new AggregateError( - failures, - `failed to roll back unconfirmed JSONL generation publication "${currentPath}"`, - ) - } + await syncDirectory(dirname(currentPath), internals) return true } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 8ce3a8151c..2d4ead8fc5 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -1,6 +1,6 @@ /** * JSONL durable session-persistence backend. It stores a header and contiguous - * events in one append-only file per session and serves the handle-based + * events in immutable generation files under one directory per session and serves the handle-based * `SessionPersistence` API: `create`/`open` return per-session handles, and * every read validates the same fail-closed storage contract. * @module @deepseek-ai/dsh-session-persistence-jsonl @@ -137,6 +137,11 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +/** Whether a filesystem-owned failure should retain its original errno and path. */ +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof (error as NodeJS.ErrnoException | null)?.code === 'string' +} + /** * The JSONL persistence backend. Load as a plugin; it registers as * `ctx.sessionPersistence`. Sessions materialize lazily: a created session is @@ -411,6 +416,7 @@ class JsonlSessionPersistence extends SessionPersistence { ...(signal === undefined ? {} : { signal }), }) } catch (error: unknown) { + signal?.throwIfAborted() if (error instanceof JsonlGenerationNewerVersionError) { const reason = sessionFormatVersionRefusal(error.storedId, error.storedVersion) throw new SessionFormatUnsupportedError( @@ -424,7 +430,14 @@ class JsonlSessionPersistence extends SessionPersistence { { kind: 'jsonl', path: selected.sourcePath }, ) } - throw error + if (error instanceof SessionFormatUnsupportedError + || error instanceof SessionPersistenceCorruptionError + || isErrnoException(error) + || error instanceof DOMException && error.name === 'AbortError') throw error + throw new SessionPersistenceCorruptionError( + `session "${id}": stored log is corrupt: ${String(error)} (raw log: ${selected.sourcePath})`, + { cause: error }, + ) } } diff --git a/packages/session/session-persistence-jsonl/tests/generation.spec.ts b/packages/session/session-persistence-jsonl/tests/generation.spec.ts index a85a246dff..dbf598bd3d 100644 --- a/packages/session/session-persistence-jsonl/tests/generation.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/generation.spec.ts @@ -571,7 +571,7 @@ describe('JSONL immutable generation publication', () => { it('never overwrites a colliding exclusive stage name', async () => { const root = await tempRoot() const request = options(root) - const collision = join(root, 'session.migration.collision.tmp.jsonl') + const collision = join(root, 'session.migration.collision.jsonl.tmp') await writeFile(request.sourcePath, line(header(0)) + line(event0)) await writeFile(collision, 'owned-by-another-attempt\n') const randomToken = vi.fn().mockReturnValueOnce('collision').mockReturnValue('stage') @@ -582,7 +582,7 @@ describe('JSONL immutable generation publication', () => { expect(await readFile(collision, 'utf8')).toBe('owned-by-another-attempt\n') expect((await readdir(root)).sort()).toEqual([ 'session.jsonl', - 'session.migration.collision.tmp.jsonl', + 'session.migration.collision.jsonl.tmp', 'session.v1.jsonl', ]) }) @@ -697,7 +697,7 @@ describe('JSONL immutable generation publication', () => { expect(barrier).toHaveBeenCalledWith('after-publication', 1) }) - it('removes an unconfirmed POSIX publication after the directory sync fails, then retries', async () => { + it('retains a POSIX publication after the directory sync fails', async () => { const root = await tempRoot() const request = options(root) const directorySyncFailure = new Error('published directory sync failed') @@ -716,40 +716,12 @@ describe('JSONL immutable generation publication', () => { request, { platform: 'darwin', fs: { open: openFile } }, )).rejects.toBe(directorySyncFailure) - expect(await readdir(root)).toEqual(['session.jsonl']) + expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v1.jsonl']) await expect(ensureJsonlGenerationCurrent(request)).resolves.toMatchObject({ path: request.currentPath }) expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(1)) + line(event0)) }) - it('preserves the primary directory-sync failure when publication rollback also fails', async () => { - const root = await tempRoot() - const request = options(root) - const directorySyncFailure = new Error('published directory sync failed') - const rollbackFailure = new Error('published target rollback failed') - await writeFile(request.sourcePath, line(header(0)) + line(event0)) - const openFile = async (path: string, flags: string, mode?: number) => { - const handle = await open(path, flags, mode) - if (path === root && flags === 'r') vi.spyOn(handle, 'sync').mockRejectedValue(directorySyncFailure) - return handle - } - - const failure = await __jsonlGenerationTest.ensure(request, { - platform: 'darwin', - fs: { - open: openFile, - rm: async (path: string) => { - if (path === request.currentPath) throw rollbackFailure - await rm(path, { force: true }) - }, - }, - }).then(() => undefined, (error: unknown) => error) - - if (!(failure instanceof AggregateError)) throw new Error('expected publication rollback aggregate') - expect(failure.errors).toEqual([directorySyncFailure, rollbackFailure, directorySyncFailure]) - expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(1)) + line(event0)) - }) - it('rethrows the exact abort reason after publication and leaves the committed target', async () => { const root = await tempRoot() const controller = new AbortController() @@ -815,7 +787,7 @@ describe('JSONL immutable generation publication', () => { it('leaves a crash-style staging file inert', async () => { const root = await tempRoot() const request = options(root) - const crashStage = join(root, 'session.migration.crash.tmp.jsonl') + const crashStage = join(root, 'session.migration.crash.jsonl.tmp') await writeFile(request.sourcePath, line(header(0)) + line(event0)) await writeFile(crashStage, line(header(99))) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index fcac78b2bd..7d0eed3d9b 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -439,8 +439,24 @@ describe('JsonlSessionPersistence: stored-format refusals', () => { await mkdir(dirname(path), { recursive: true }) await writeFile(path, '42\n') const failure = await ctx.sessionPersistence.open(id, 'read').then(() => undefined, (error: unknown) => error as Error) - expect(failure?.name).not.toBe('SessionFormatUnsupportedError') + expect(failure?.name).toBe('SessionPersistenceCorruptionError') expect(failure?.message).toContain('first line is not a JSON object') + expect(failure?.message).toContain(`(raw log: ${path})`) + }) + + it.each([ + ['invalid JSON', 'not json\n', /not valid JSON/], + ['missing header newline', '{"type":"session"}', /header-less session log/], + ])('classifies a %s current log as corruption with its raw path', async (name, content, reason) => { + const id = SessionId(`corrupt-${name.replaceAll(' ', '-')}`) + const path = rawLogPath(root, '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) + + const failure = await ctx.sessionPersistence.open(id, 'read').then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionPersistenceCorruptionError') + expect(failure?.message).toMatch(reason) + expect(failure?.message).toContain(`(raw log: ${path})`) }) it('names a foreign-version header by its stringified non-string id', async () => { diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 51e3de943e..fa346cd9e5 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md -README.md: 56adf71d1dda7e45df6f3ea14ed958f8cde150bf -README.zh.md: ddaa203f7b9fab48f373edbb6c1fae4a5b8cb43d +README.md: ba90e838763787d28e5b6e278d868504fc672c5f +README.zh.md: 8b3fe4148d408af75194212e4787a7cdb9030507 diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 56adf71d1d..ba90e83876 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -58,7 +58,7 @@ Three mandatory points always write: session creation persists the seed-derived ### Reading cached values -`cachedSnapshot(meta, inheritedEventCount)` synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts only an identity-matching record and version- and schema-matching keys, then returns a `{ asOfSeq, values }` cut at the lowest served-row watermark. `cachedPredecessorTitle(meta, inheritedEventCount)` is the narrower listing-only exception: a structurally admitted predecessor record whose lifecycle matches may expose only a current-version-compatible `title` row. The title is a possibly stale fact from a durable prefix, not a fold seed; all other predecessor rows remain unavailable. An unseeded listing knows that its cut is zero; a seeded header-only listing does not know the numeric cut and skips both fast paths until an authoritative body read supplies it. `coldSnapshot(meta, inheritedEventCount, events)` accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself. +`cachedSnapshot(meta, inheritedEventCount)` synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts only an identity-matching record and version- and schema-matching keys, then returns a `{ asOfSeq, values }` cut at the lowest served-row watermark. `cachedPredecessorTitle(meta, inheritedEventCount)` is the narrower listing-only exception: a structurally admitted predecessor record whose lifecycle matches may expose only a current-version-compatible `title` row. The title is a possibly stale fact from a durable prefix, not a fold seed; it carries the sentinel `asOfSeq: -1` because a cardinality-changing Session migration can invalidate the predecessor row's numeric sequence. All other predecessor rows remain unavailable. An unseeded listing knows that its cut is zero; a seeded header-only listing does not know the numeric cut and skips both fast paths until an authoritative body read supplies it. `coldSnapshot(meta, inheritedEventCount, events)` accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself. ### What the cache guarantees diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index ddaa203f7b..8b3fe4148d 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" ### 读取缓存值 -`cachedSnapshot(meta, inheritedEventCount)` 以零 I/O 从存储域的内存表同步提供客户端值。它只接受身份匹配的记录以及版本和 schema 均匹配的 key,再按所服务行的最低水位返回 `{ asOfSeq, values }` 切面。`cachedPredecessorTitle(meta, inheritedEventCount)` 是更窄的列表专用例外:生命周期匹配且已通过结构准入的 predecessor record 只能公开与当前版本兼容的 `title` row。该 title 是 durable prefix 中可能过时的事实,而不是 fold seed;其他 predecessor row 仍不可用。未 seeded 的列表知道切点为零;仅 header 的 seeded 列表不知道数字切点,因此两条快速路径都要跳过,直到权威正文读取提供它。`coldSnapshot(meta, inheritedEventCount, events)` 接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 +`cachedSnapshot(meta, inheritedEventCount)` 以零 I/O 从存储域的内存表同步提供客户端值。它只接受身份匹配的记录以及版本和 schema 均匹配的 key,再按所服务行的最低水位返回 `{ asOfSeq, values }` 切面。`cachedPredecessorTitle(meta, inheritedEventCount)` 是更窄的列表专用例外:生命周期匹配且已通过结构准入的 predecessor record 只能公开与当前版本兼容的 `title` row。该 title 是 durable prefix 中可能过时的事实,而不是 fold seed;它携带 sentinel `asOfSeq: -1`,因为改变事件数量的 Session 迁移会使 predecessor row 的数字序号失效。其他 predecessor row 仍不可用。未 seeded 的列表知道切点为零;仅 header 的 seeded 列表不知道数字切点,因此两条快速路径都要跳过,直到权威正文读取提供它。`coldSnapshot(meta, inheritedEventCount, events)` 接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 ### 缓存保证什么 diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 945d651fa5..639dfcc978 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -164,8 +164,10 @@ export class SessionProjectionCache extends Service { * strict {@link cachedSnapshot} / hydration paths continue to reject them. * @param meta - authoritative listed Session header. * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. - * @returns a title-only checkpoint view, or `undefined` when the record is - * current, newer, unrelated, missing, or incompatible with the title unit. + * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined` + * when the record is current, newer, unrelated, missing, or incompatible + * with the title unit. The sentinel avoids reusing a sequence that a + * cardinality-changing Session migration may have remapped. */ cachedPredecessorTitle( meta: SessionHeader, @@ -174,7 +176,8 @@ export class SessionProjectionCache extends Service { const expected = identityOf(meta, inheritedEventCount) const record = this.requireTable().get(meta.id) if (record === undefined || !predecessorIdentityMatches(record.identity, expected)) return undefined - return this.viewRecord(record, [PREDECESSOR_TITLE_KEY]) + const title = this.viewRecord(record, [PREDECESSOR_TITLE_KEY]) + return title === undefined ? undefined : { ...title, asOfSeq: -1 } } /** View selected wire rows and bind them to their lowest served watermark. */ diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index e4e6ebca0d..4377fadcf2 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -166,7 +166,7 @@ describe('archived version recovery', () => { headerFor(SessionId(sid), record.identity), SessionLogOffset(0), )).toEqual({ - asOfSeq: record.rows.title?.seq, + asOfSeq: -1, values: { title: record.rows.title?.val }, }) @@ -200,7 +200,7 @@ describe('archived version recovery', () => { headerFor(id, doc.record.identity), SessionLogOffset(0), )).toEqual({ - asOfSeq: doc.record.rows.title?.seq, + asOfSeq: -1, values: { title: doc.record.rows.title?.val }, }) @@ -241,7 +241,7 @@ describe('archived version recovery', () => { isSeeded: false, }) expect(cache.cachedPredecessorTitle(listed('older'), SessionLogOffset(0))).toEqual({ - asOfSeq: 2, + asOfSeq: -1, values: { title: 'older title' }, }) expect(cache.cachedPredecessorTitle(listed('current'), SessionLogOffset(0))).toBeUndefined() diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index 4f56fb0a95..b73fd5c181 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-telemetry/README.md -README.md: 77aa347ba4bb82191f13b8d15aeeb1c7bb15c2d4 -README.zh.md: 487eed63b49f54a1f93951bd473366c02bf99c0f +README.md: 2e20de1befe06801ae06de9da6ccc234ee8bdec3 +README.zh.md: d83c9763170e35a5a3ed6ecf1a0b363e438b0723 diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md index 77aa347ba4..2e20de1bef 100644 --- a/packages/session/session-telemetry/README.md +++ b/packages/session/session-telemetry/README.md @@ -70,15 +70,15 @@ The seam is built on one boundary: the harness's aspect ends at `emit()`. Comple | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Service Definition: `SessionTelemetryBackend`/`SessionTelemetrySink` contract, record vocabulary, `session-telemetry/record` waterfall declaration | -| [`src/coordinator.ts`](src/coordinator.ts) | Capture: live listeners, complete on-demand replay, redaction, handoff cursor, containment | +| [`src/coordinator.ts`](src/coordinator.ts) | Capture: live listeners, lifecycle-local on-demand replay, redaction, handoff cursor, containment | ### Capture flow -Live capture registers, through the composing fiber's effects: `session/created` adopts the session and replays its log from the handoff cursor; `session/event` deep-copies, redacts, and hands off each event with zero I/O; `session/flush` forwards the optional hint and returns void so the loop's awaited parallel never waits on telemetry; `session/disposed` captures the session's `shutdown` marker and retires it; `agent/error` is the one live-bus relay, because the session-event vocabulary intentionally has no operational-error record. Disposal captures shutdown markers for still-live sessions, then awaits the backend's `shutdown()`. On-demand capture registers only the disposal effect and reads the complete requested canonical-log prefix on request. Every synchronous handler runs inside containment so a failing backend or rule can never starve other listeners or reach the agent loop. +Live capture registers, through the composing fiber's effects: `session/created` adopts the session and replays its lifecycle-local log suffix from the handoff cursor; `session/event` deep-copies, redacts, and hands off each event with zero I/O; `session/flush` forwards the optional hint and returns void so the loop's awaited parallel never waits on telemetry; `session/disposed` captures the session's `shutdown` marker and retires it; `agent/error` is the one live-bus relay, because the session-event vocabulary intentionally has no operational-error record. Disposal captures shutdown markers for still-live sessions, then awaits the backend's `shutdown()`. On-demand capture registers only the disposal effect and reads the requested lifecycle-local canonical-log prefix on request. Every synchronous handler runs inside containment so a failing backend or rule can never starve other listeners or reach the agent loop. ### The handoff cursor -A module-scope `WeakMap` records, per Session object, the highest seq handed off (not delivered). Live capture advances it at append time; on-demand capture advances it only while handing a requested prefix. Re-adopting the same object resumes after that cursor and does not duplicate its handed-off ledger records. A new Session object has no cursor, so capture starts at seq 0 and includes its complete constructor seed, whether the object represents a fresh session, fork, resume, or migrated stored log. Receivers absorb this deliberate full-log replay and SDK retries by deduplicating on `(session.id, session.format_version, event.seq)`. The object-keyed map is a narrow, documented exception to the registrations-are-effects discipline: entries die with their sessions, and losing one only causes a safe full replay. +A module-scope `WeakMap` records, per Session object, the highest seq handed off (not delivered). Live capture advances it at append time; on-demand capture advances it only while handing a requested prefix. Re-adopting the same object resumes after that cursor and does not duplicate its handed-off ledger records. A new Session object starts immediately before `firstLiveSeq`: a fresh object starts at seq 0, while a forked, resumed, or migrated object skips its constructor seed and starts with this lifecycle's `session/end-seed` boundary. This keeps inherited and previously persisted history outside a new lifecycle's sharing act. Receivers absorb SDK retries by deduplicating on `(session.id, session.format_version, event.seq)`. The object-keyed map is a narrow, documented exception to the registrations-are-effects discipline: entries die with their sessions, and losing one can replay only the current lifecycle suffix. diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index 487eed63b4..d83c976317 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -70,15 +70,15 @@ seam 建立在一个边界之上:harness 的职责止于 `emit()`。完整事 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | Service Definition:`SessionTelemetryBackend`/`SessionTelemetrySink` 约定、记录词汇、`session-telemetry/record` waterfall 声明 | -| [`src/coordinator.ts`](src/coordinator.ts) | 捕获:live 监听器、完整 on-demand 回放、脱敏、handoff 游标、异常隔离 | +| [`src/coordinator.ts`](src/coordinator.ts) | 捕获:live 监听器、生命周期本地 on-demand 回放、脱敏、handoff 游标、异常隔离 | ### 捕获流程 -live 捕获通过组合方 fiber 的 effect 注册:`session/created` 收养会话并从 handoff 游标起回放其日志;`session/event` 深拷贝、脱敏并交接每个事件,零 I/O;`session/flush` 转发可选的提示并返回 void,使循环所等待的并行任务绝不等待遥测;`session/disposed` 捕获会话的 `shutdown` 标记并退役它;`agent/error` 是唯一的实时总线转发,因为会话事件词汇有意不包含运维错误记录。dispose 会为仍存活的会话捕获 shutdown 标记,然后等待后端的 `shutdown()`。on-demand 捕获只注册 dispose effect,并在请求时读取完整的权威日志请求前缀。每个同步处理器都运行在异常隔离之内,使失败的后端或规则永远不会饿死其他监听器,也永远不会触及 agent loop。 +live 捕获通过组合方 fiber 的 effect 注册:`session/created` 收养会话并从 handoff 游标起回放其生命周期本地日志后缀;`session/event` 深拷贝、脱敏并交接每个事件,零 I/O;`session/flush` 转发可选的提示并返回 void,使循环所等待的并行任务绝不等待遥测;`session/disposed` 捕获会话的 `shutdown` 标记并退役它;`agent/error` 是唯一的实时总线转发,因为会话事件词汇有意不包含运维错误记录。dispose 会为仍存活的会话捕获 shutdown 标记,然后等待后端的 `shutdown()`。on-demand 捕获只注册 dispose effect,并在请求时读取所请求的生命周期本地权威日志前缀。每个同步处理器都运行在异常隔离之内,使失败的后端或规则永远不会饿死其他监听器,也永远不会触及 agent loop。 ### handoff 游标 -一个模块作用域的 `WeakMap` 按 Session 对象记录已交接(而非已投递)的最高 seq。live 捕获在追加时推进它;on-demand 捕获只在交接所请求的前缀时推进它。重新收养同一对象时会从该游标之后继续,且不会重复交接其 ledger 记录。新 Session 对象没有游标,因此捕获从 seq 0 开始,并包含完整的构造 seed;无论该对象表示全新会话、fork、resume 还是已迁移的存储日志,规则都相同。接收端基于 `(session.id, session.format_version, event.seq)` 去重,以吸收这项有意的全日志回放以及 SDK 重试。这个以对象为键的 map 是对「注册即 effect」纪律的一次有意的、有文档说明的窄例外:条目随其 Session 消亡;丢失条目只会触发一次安全的全量回放。 +一个模块作用域的 `WeakMap` 按 Session 对象记录已交接(而非已投递)的最高 seq。live 捕获在追加时推进它;on-demand 捕获只在交接所请求的前缀时推进它。重新收养同一对象时会从该游标之后继续,且不会重复交接其 ledger 记录。新 Session 对象从 `firstLiveSeq` 之前开始:全新对象从 seq 0 开始,而 fork、resume 或迁移对象会跳过 constructor seed,从本生命周期的 `session/end-seed` 边界开始。这样,继承历史与此前持久化历史不会进入新生命周期的共享动作。接收端基于 `(session.id, session.format_version, event.seq)` 对 SDK 重试去重。这个以对象为键的 map 是对「注册即 effect」纪律的一次有意且有文档说明的窄例外:条目随其 Session 消亡;丢失条目最多只会回放当前生命周期后缀。 diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index 67008b6aa4..009c561373 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -15,7 +15,13 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { Session, SessionEvent, SessionSeq as SessionSeqType, SessionSeqCursor } from '@deepseek-ai/dsh-session' +import { + SessionSeq, + type Session, + type SessionEvent, + type SessionSeq as SessionSeqType, + type SessionSeqCursor, +} from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionTelemetrySink, SessionTelemetryRecord, SessionTelemetrySeverity } from './index.ts' @@ -134,7 +140,8 @@ export class SessionTelemetryCoordinator { * @param throughSeq - optional last sequence included in this capture. */ captureSession(session: Session, throughSeq?: SessionSeqType): void { - const cursor = handoffCursor.get(session) ?? -1 + const cursor = handoffCursor.get(session) + ?? (session.firstLiveSeq === 0 ? -1 : SessionSeq(session.firstLiveSeq - 1)) // Containment is PER EVENT: one rejected record is withheld fail-closed // while the rest of the historical replay proceeds. for (const event of session.snapshotEvents()) { @@ -147,10 +154,11 @@ export class SessionTelemetryCoordinator { } /** - * Adopt a session: replay its log after the same-object handoff cursor, then - * rely on the firehose for everything after. A newly constructed Session - * object has no cursor, so replay begins at seq 0 and includes constructor - * seed history. Re-adopting the same object resumes after its cursor. + * Adopt a session: replay this lifecycle's log suffix after the same-object + * handoff cursor, then rely on the firehose for everything after. A newly + * constructed Session object starts at its constructor boundary, so inherited + * or restored seed history is not attributed to this lifecycle. Re-adopting + * the same object resumes after its cursor. * @param session - the live session to adopt; a second adoption is a no-op. */ private adopt(session: Session): void { diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index e98b4c0cc0..3b791ff2a2 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -2,7 +2,7 @@ import { createAssistantMessage, createToolResultMessage, createUserMessage } fr /** * Coordinator semantics against a bare fake backend — the RFC's named unit * tier for the seam: adoption (fresh, seeded, re-adoption via the handoff - * cursor), complete-log replay, deep-copy isolation, turn-latency and + * cursor), lifecycle-suffix replay, deep-copy isolation, turn-latency and * dispose-ordering pins, failure containment, and the `agent/error` relay. */ @@ -338,7 +338,7 @@ describe('SessionTelemetryCoordinator on-demand capture', () => { }) describe('SessionTelemetryCoordinator adoption', () => { - it('replays a new fork object from seq -1, including its inherited prefix', async () => { + it('replays a new fork object from its constructor boundary without its inherited prefix', async () => { const backend = new FakeBackend() const ctx = new Context() await ctx.plugin(SessionStore) @@ -357,14 +357,12 @@ describe('SessionTelemetryCoordinator adoption', () => { const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]) expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]])) expect(seqs.filter(([id]) => id === 'seeded')).toEqual([ - ['seeded', 0], - ['seeded', 1], ['seeded', 2], ['seeded', 3], ]) }) - it('replays a restored post-migration Session from seq -1 with current-version identity', async () => { + it('replays a restored post-migration Session from its constructor boundary', async () => { const backend = new FakeBackend() const ctx = new Context() await ctx.plugin(SessionStore) @@ -394,14 +392,12 @@ describe('SessionTelemetryCoordinator adoption', () => { ctx.sessions.announce(resumed) const ofResumed = () => backend.ledger() .filter(r => r.attributes['session.id'] === 'resumed') - expect(ofResumed().map(r => r.attributes['event.seq'])).toEqual([0, 1, 2]) + expect(ofResumed().map(r => r.attributes['event.seq'])).toEqual([2]) expect(ofResumed().every(r => r.attributes['session.format_version'] === SESSION_FORMAT_VERSION)).toBe(true) appendAssistantMessage(resumed, 1, 1, ['continuation'], 200) appendAssistantMessage(resumed, 1, 2, ['next step'], 300) - expect(ofResumed().map(r => r.attributes['event.seq'])).toEqual([0, 1, 2, 3, 4]) + expect(ofResumed().map(r => r.attributes['event.seq'])).toEqual([2, 3, 4]) expect(ofResumed().map(r => (r.body as { stream?: { type: string; texts?: string[] }[] }).stream?.[0]?.texts)).toEqual([ - undefined, - ['first'], undefined, ['continuation'], ['next step'], diff --git a/packages/test-support/llm-replay/README.i18n.yaml b/packages/test-support/llm-replay/README.i18n.yaml index cbf13b583c..382b98d0a4 100644 --- a/packages/test-support/llm-replay/README.i18n.yaml +++ b/packages/test-support/llm-replay/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/llm-replay/README.md -README.md: ca555f670c7674e269e977ec4fc7c2cc73a9f42f -README.zh.md: e8e8bf11df0107263f5d909816fdf3164f33d440 +README.md: 94759958796b1e9de2cce7b0cf5dea5f1dec9ec5 +README.zh.md: a9819e3af9504f313b7d9b527e384014dee9e772 diff --git a/packages/test-support/llm-replay/README.md b/packages/test-support/llm-replay/README.md index ca555f670c..9475995879 100644 --- a/packages/test-support/llm-replay/README.md +++ b/packages/test-support/llm-replay/README.md @@ -65,7 +65,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### How the fixture works -The fixture is a projection of one selected persisted Session generation produced by running the real agent once — this plugin does not record. The snapshot harness supplies the numerically highest canonical parent path (`/session.jsonl` for v0 or `/session.vN.jsonl` for a positive generation), and validates filename/header agreement before replay. The fixture keeps the header and every event payload but omits body `seq`/`time` envelopes (`seq0`/`time0` for historical packed rows). Replay supplies contiguous sequences and deterministic timestamps, restores typed values replaced by snapshot tokens, rejects partial or mixed envelopes, decodes the complete physical artifact through the build-static Session format catalog, and migrates historical input in memory before it exposes events or the inherited cut; current input takes direct restoration. For a projected v0 header only, an absent `delegationDepth` denotes `0`. The parser never rewrites or renames the fixture. Runtime persistence continues to write complete logs. Replay expands the compact stream on each current-view `assistant/message` or `assistant/attempt`, so a recorded fixture replays the same logical stream the live model produced. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}`; replay materializes validation-only values, while derivation reads only Assistant settlements, marked summary events, and Session metadata. Two exact repository v0 fixtures use source-qualified repair only after the catalog returns their manifest-pinned alpha refusal; pathless parsing, copied lookalikes, and changed refusal diagnostics remain strict. This exception affects replay and expected-output comparison only, while the real catalog and persistence continue to refuse those artifacts. +The fixture is a projection of one selected persisted Session generation produced by running the real agent once — this plugin does not record. The snapshot harness supplies the numerically highest canonical parent path (`/session.jsonl` for v0 or `/session.vN.jsonl` for a positive generation), and validates filename/header agreement before replay. The fixture keeps the header and every event payload but omits body `seq`/`time` envelopes (`seq0`/`time0` for historical packed rows). Replay supplies contiguous sequences and deterministic timestamps, restores typed values replaced by snapshot tokens, rejects partial or mixed envelopes, decodes the complete physical artifact through the build-static Session format catalog, and migrates historical input in memory before it exposes events or the inherited cut; current input takes direct restoration. For a projected v0 header only, an absent `delegationDepth` denotes `0`. The parser never rewrites or renames the fixture. Runtime persistence continues to write complete logs. Replay expands the compact stream on each current-view `assistant/message` or `assistant/attempt`, so a recorded fixture replays the same logical stream the live model produced. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}`; replay materializes validation-only values, while derivation reads only Assistant settlements, marked summary events, and Session metadata. Every committed versioned fixture passes the same strict catalog restoration; malformed historical fixtures are repaired at their source instead of receiving path-dependent replay policy. ### Nested agents @@ -97,15 +97,14 @@ This section explains the design of the replay plugin; the observable behavior i Replay treats the selected projected Session generation as the fixture. One parser completes projected envelopes, validates and migrates the whole artifact through `sessionFormatCatalog`, and returns the current header, inherited cut, and event list as one result. `deriveReplayScript` expands each `assistant/message` or `assistant/attempt` stream in log order, so each durable settlement becomes one `chunks` entry; a non-empty stream without a `finish` chunk is the fingerprint of a thrown `stream()` and must be expressed through an override sidecar. A `compaction/summary` carrying `llmStreamCall: true` and a complete `rawOutput` replays as one canonical successful stream at that event's position. Scripted strings may embed `{{fromRequest:}}`; at stream time each placeholder resolves against the live request's string leaves, taking the pattern's last match and its first capture group (or the whole match) in place. -The committed-corpus test discovers every versioned `session*.jsonl` under `snapshots/`, `packages/`, and `scripts/snapshots/python-sdk-single-exe/`. Every artifact must restore to the current view through the real catalog except two exact manifest refusals: `snapshots/session/agent-instructions/session.jsonl` has an unmatched projected compaction checkpoint, and `snapshots/web/schedule-catalog/session.jsonl` has a title source that contradicts its citations. The shared manifest grants replay-only extraction to those same absolute paths while the corpus continues to assert their real unsupported-migration class and message. A new or changed refusal fails until its underlying data rule is resolved explicitly. +The committed-corpus test discovers every versioned `session*.jsonl` under `snapshots/`, `packages/`, and `scripts/snapshots/python-sdk-single-exe/`. Every artifact must restore to the current view through the real catalog. A new refusal fails the corpus until its fixture or the catalog policy is corrected explicitly. ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Types, fixture derivation, override validation, placeholder resolution, session binding, `installLlmReplay`, and the plugin export | -| [`src/alpha-refusal-fixtures.ts`](src/alpha-refusal-fixtures.ts) | Closed source-path and diagnostic manifest for the two replay-only alpha refusals | -| [`tests/session-format-corpus.spec.ts`](tests/session-format-corpus.spec.ts) | Complete committed-generation restoration burn-in and the closed alpha-refusal manifest | +| [`tests/session-format-corpus.spec.ts`](tests/session-format-corpus.spec.ts) | Complete committed-generation restoration burn-in | | — | No runtime invariant companion is published; this test-only adapter consumes a fixed replay script; its stream grammar is checked by the LLM companion and fixture derivation tests. | ### Binding and stream flow diff --git a/packages/test-support/llm-replay/README.zh.md b/packages/test-support/llm-replay/README.zh.md index e8e8bf11df..a9819e3af9 100644 --- a/packages/test-support/llm-replay/README.zh.md +++ b/packages/test-support/llm-replay/README.zh.md @@ -65,7 +65,7 @@ kind: "package-reference" ### fixture 的工作方式 -fixture 是运行一次真实 agent 所产生的一份选定持久化 Session generation 投影,本插件不录制。snapshot harness 会提供数值最高的规范 parent 路径(v0 为 `/session.jsonl`,正 generation 为 `/session.vN.jsonl`),并在 replay 前校验文件名与 header 一致。fixture 保留 header 与每个事件 payload,但省略正文的 `seq`/`time` envelope(历史 packed row 使用 `seq0`/`time0`)。replay 补充连续序号与确定性 timestamp,恢复被 snapshot token 替换的类型化值,拒绝不完整或混合 envelope,通过构建期静态 Session 格式 catalog 解码完整物理产物,并在公开事件或继承 cut 前于内存中迁移历史输入;当前输入直接 restore。仅对投影 v0 header,缺失的 `delegationDepth` 表示 `0`。parser 从不重写或重命名 fixture。runtime persistence 继续写入完整日志。replay 会展开当前视图中每个 `assistant/message` 或 `assistant/attempt` 的紧凑 stream,因此已记录 fixture 会 replay 与在线模型产生的相同逻辑流。fixture 的 `request/header` 内容可能 token 化为 `{{system}}`/`{{tools}}`;replay 会物化仅用于校验的值,而派生只读取 Assistant settlement、带标记的 summary 事件与 Session metadata。仓库中的两个精确 v0 fixture 只在 catalog 返回 manifest 固定的 alpha 拒绝后使用来源限定的 repair;无路径解析、复制的相似 fixture 与变化后的拒绝诊断仍保持严格。此例外只影响 replay 与预期输出比较,真实 catalog 与 persistence 继续拒绝这些产物。 +fixture 是运行一次真实 agent 所产生的一份选定持久化 Session generation 投影,本插件不录制。snapshot harness 会提供数值最高的规范 parent 路径(v0 为 `/session.jsonl`,正 generation 为 `/session.vN.jsonl`),并在 replay 前校验文件名与 header 一致。fixture 保留 header 与每个事件 payload,但省略正文的 `seq`/`time` envelope(历史 packed row 使用 `seq0`/`time0`)。replay 补充连续序号与确定性 timestamp,恢复被 snapshot token 替换的类型化值,拒绝不完整或混合 envelope,通过构建期静态 Session 格式 catalog 解码完整物理产物,并在公开事件或继承 cut 前于内存中迁移历史输入;当前输入直接 restore。仅对投影 v0 header,缺失的 `delegationDepth` 表示 `0`。parser 从不重写或重命名 fixture。runtime persistence 继续写入完整日志。replay 会展开当前视图中每个 `assistant/message` 或 `assistant/attempt` 的紧凑 stream,因此已记录 fixture 会 replay 与在线模型产生的相同逻辑流。fixture 的 `request/header` 内容可能 token 化为 `{{system}}`/`{{tools}}`;replay 会物化仅用于校验的值,而派生只读取 Assistant settlement、带标记的 summary 事件与 Session metadata。每个已提交且带版本的 fixture 都经过同一条严格 catalog 恢复路径;malformed 历史 fixture 在来源处修复,不获得依赖路径的 replay 策略。 ### 嵌套 agent @@ -97,15 +97,14 @@ parent agent 委托给进程内 subagent 的场景会为每个 Session 记录一 replay 把选定的投影 Session generation 视为 fixture。一个 parser 补全投影 envelope,通过 `sessionFormatCatalog` 校验并迁移完整产物,再以一个结果返回当前 header、继承 cut 与事件列表。`deriveReplayScript` 按日志顺序展开每个 `assistant/message` 或 `assistant/attempt` stream,因此每个持久 settlement 都成为一条 `chunks` entry;非空 stream 缺少 `finish` chunk 是 `stream()` 抛出异常的 fingerprint,必须通过 override sidecar 表达。携带 `llmStreamCall: true` 与完整 `rawOutput` 的 `compaction/summary` 会在该事件位置 replay 为一条规范成功 stream。脚本字符串可以内嵌 `{{fromRequest:}}`;stream 输出时每个 placeholder 针对 live request 的 string leaf 解析,取该 pattern 的最后一次 match,用其第一个 capture group(无 capture group 时用整个 match)原位替换。 -已提交语料测试会发现 `snapshots/`、`packages/` 与 `scripts/snapshots/python-sdk-single-exe/` 下每个带版本的 `session*.jsonl`。除 manifest 中两项精确拒绝外,每个产物都必须通过真实目录还原为当前视图:`snapshots/session/agent-instructions/session.jsonl` 的投影 compaction checkpoint 没有匹配项,`snapshots/web/schedule-catalog/session.jsonl` 的 title 来源与其 citation 矛盾。共享 manifest 只为相同绝对路径授予仅回放提取,而语料仍断言真实的不受支持迁移类型与诊断。新的或发生变化的拒绝会使测试失败,直到其底层数据规则得到显式处理。 +已提交语料测试会发现 `snapshots/`、`packages/` 与 `scripts/snapshots/python-sdk-single-exe/` 下每个带版本的 `session*.jsonl`。每个产物都必须通过真实 catalog 还原为当前视图。新的拒绝会使语料测试失败,直到 fixture 或 catalog 策略得到显式修正。 ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 类型、fixture 派生、override 校验、占位符解析、会话绑定、`installLlmReplay` 与插件导出 | -| [`src/alpha-refusal-fixtures.ts`](src/alpha-refusal-fixtures.ts) | 两项仅回放 alpha 拒绝的封闭来源路径与诊断 manifest | -| [`tests/session-format-corpus.spec.ts`](tests/session-format-corpus.spec.ts) | 完整已提交 generation restore burn-in 与封闭 alpha 拒绝 manifest | +| [`tests/session-format-corpus.spec.ts`](tests/session-format-corpus.spec.ts) | 完整已提交 generation restore burn-in | | — | 不发布运行时不变式伴生入口;流语法由 LLM 伴生插件与派生测试检验。 | ### 绑定与流式流程 diff --git a/packages/test-support/llm-replay/src/alpha-refusal-fixtures.ts b/packages/test-support/llm-replay/src/alpha-refusal-fixtures.ts deleted file mode 100644 index c5474d7cd2..0000000000 --- a/packages/test-support/llm-replay/src/alpha-refusal-fixtures.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** Exact repository fixtures allowed to bypass migration only for test replay and comparison. */ - -import { resolve } from 'node:path' - -/** One committed alpha fixture whose real catalog refusal remains required. */ -export interface AlphaSessionFormatRefusalFixture { - /** Repository-relative source identity used by corpus diagnostics. */ - readonly repoRelativePath: string - /** Exact absolute source path accepted by replay-only helpers. */ - readonly path: string - /** Exact source-qualified migration diagnostic the corpus must retain. */ - readonly expectedMessage: string - /** Exact current-generation relationship diagnostic admitted during paired comparison. */ - readonly expectedCurrentMessage?: string -} - -const REPOSITORY_ROOT = resolve(import.meta.dirname, '../../../..') - -/** Closed replay-only exception inventory; production persistence never imports it. */ -export const ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES: readonly AlphaSessionFormatRefusalFixture[] = Object.freeze([ - Object.freeze({ - repoRelativePath: 'snapshots/session/agent-instructions/session.jsonl', - path: resolve(REPOSITORY_ROOT, 'snapshots/session/agent-instructions/session.jsonl'), - expectedMessage: 'session snapshot line 22: @deepseek-ai/dsh-session-format-v0-to-v1 refuses this format v0 Session: compaction checkpoint at seq 20 has no matching compaction/start', - expectedCurrentMessage: 'session snapshot line 17: compaction checkpoint at seq 15 has no matching compaction/start', - }), - Object.freeze({ - repoRelativePath: 'snapshots/web/schedule-catalog/session.jsonl', - path: resolve(REPOSITORY_ROOT, 'snapshots/web/schedule-catalog/session.jsonl'), - expectedMessage: 'session snapshot line 4: @deepseek-ai/dsh-session-format-v0-to-v1 refuses this format v0 Session: session/title 2 messageSeqs must be empty exactly for a user title', - }), -]) - -const BY_PATH: ReadonlyMap = new Map( - ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES.map(fixture => [fixture.path, fixture]), -) - -/** - * Resolve one exact replay-only exception without admitting copied lookalikes. - * @param sourcePath - caller-supplied fixture source path. - * @returns the matching closed-manifest entry, or `undefined`. - */ -export function alphaSessionFormatRefusalForPath( - sourcePath: string, -): AlphaSessionFormatRefusalFixture | undefined { - return BY_PATH.get(resolve(sourcePath)) -} diff --git a/packages/test-support/llm-replay/src/index.ts b/packages/test-support/llm-replay/src/index.ts index ba082f92b9..03689ec683 100644 --- a/packages/test-support/llm-replay/src/index.ts +++ b/packages/test-support/llm-replay/src/index.ts @@ -33,13 +33,6 @@ import type { } from '@deepseek-ai/dsh-llm' import { LlmAdapter, LlmError, ReasoningEffortId, expandAssistantStream, requestImageHandleText, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' -import { alphaSessionFormatRefusalForPath } from './alpha-refusal-fixtures.ts' - -export { - ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES, - alphaSessionFormatRefusalForPath, - type AlphaSessionFormatRefusalFixture, -} from './alpha-refusal-fixtures.ts' const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks']) @@ -215,18 +208,8 @@ export function parseSessionLog(text: string): SessionEvent[] { return parseSessionFixture(text).events } -/** - * Parse one source-qualified fixture for replay, admitting only the closed alpha-refusal manifest. - * @param text - raw persisted or projected Session JSONL. - * @param sourcePath - exact committed fixture path used to select replay-only policy. - * @returns source events for replay-only test adapters. - */ -export function parseSessionLogForReplay(text: string, sourcePath: string): SessionEvent[] { - return parseSessionFixture(text, sourcePath).events -} - /** Parse, complete, decode, and migrate one projected snapshot artifact without writing its source. */ -function parseSessionFixture(text: string, replaySourcePath?: string): ParsedSessionFixture { +function parseSessionFixture(text: string): ParsedSessionFixture { const parsed: FixtureJsonLine[] = [] for (const [index, line] of text.split(/\r?\n/).entries()) { if (line.trim().length === 0) continue @@ -283,47 +266,23 @@ function parseSessionFixture(text: string, replaySourcePath?: string): ParsedSes decoded = sessionFormatCatalog.decodeArtifact(header, rows) } catch (error: unknown) { const physicalRow = locateUnlabelledPhysicalFailure(error, header, rows) - const failure = fixtureFormatError( + throw fixtureFormatError( error, headerLine.lineNumber, rowLines, eventLines, physicalRow, ) - const refusal = replaySourcePath === undefined - ? undefined - : alphaSessionFormatRefusalForPath(replaySourcePath) - if (refusal?.expectedCurrentMessage === failure.message - && header['version'] === SESSION_FORMAT_VERSION) { - decoded = sessionFormatCatalog.decodeArtifact( - header, - repairAlphaRefusalEvents(rows, refusal.repoRelativePath), - ) - } else { - throw failure - } } try { const current = sessionFormatCatalog.migrate(decoded) return parsedSessionFixture(current, headerLine.value) } catch (error: unknown) { - const failure = fixtureFormatError(error, headerLine.lineNumber, rowLines, eventLines) - const refusal = replaySourcePath === undefined - ? undefined - : alphaSessionFormatRefusalForPath(replaySourcePath) - if (refusal !== undefined - && failure instanceof SessionFormatUnsupportedMigrationError - && failure.message === refusal.expectedMessage) { - return parsedSessionFixture( - migrateAlphaRefusalForComparison(decoded, refusal.repoRelativePath), - headerLine.value, - ) - } - throw failure + throw fixtureFormatError(error, headerLine.lineNumber, rowLines, eventLines) } } -/** Materialize the common replay view from a migrated artifact or an exact replay-only v0 refusal. */ +/** Materialize the common replay view from a migrated artifact. */ function parsedSessionFixture( artifact: ReturnType, sourceHeader: Readonly>, @@ -338,43 +297,6 @@ function parsedSessionFixture( } } -/** Structurally migrate one closed invalid-v0 fixture without weakening production validation. */ -function migrateAlphaRefusalForComparison( - source: ReturnType, - repoRelativePath: string, -): ReturnType { - const repaired = { - ...structuredClone(source), - events: repairAlphaRefusalEvents(source.events, repoRelativePath), - } - return sessionFormatCatalog.migrate(repaired) -} - -/** Repair the exact known invalid relationship before test-only migration or comparison. */ -function repairAlphaRefusalEvents[]>( - sourceEvents: T, - repoRelativePath: string, -): T { - const events = structuredClone(sourceEvents) - if (repoRelativePath === 'snapshots/session/agent-instructions/session.jsonl') { - const compact = events.find((event) => { - if (event.type !== 'user/message') return false - const provenance = (event.data as Record)['source'] - return typeof provenance === 'object' && provenance !== null - && (provenance as Record)['plugin'] === 'compact' - }) as Record - const provenance = ((compact['data'] as Record)['source']) as Record - provenance['plugin'] = 'alpha-comparison-compact' - delete provenance['compactionId'] - delete provenance['sourceCommandId'] - } else { - const title = events.find(event => event.type === 'session/title') as Record - const sourceRecord = ((title['data'] as Record)['source']) as Record - sourceRecord['kind'] = 'fallback' - } - return events -} - /** * Convert one persisted or projected snapshot fixture to the current physical format in memory. * Projected cwd tokens remain tokens so the ordinary snapshot normalizer can compare them with a fresh run. @@ -387,18 +309,12 @@ export function migrateSessionSnapshotFixture(text: string): string { } /** - * Prepare one source-qualified fixture for expected-output comparison. - * Exact alpha refusals receive their source-qualified test repair before migration; - * production decoding and migration remain strict. + * Prepare one fixture for expected-output comparison through strict format validation. * @param text - one complete Session fixture. - * @param sourcePath - exact committed source path, when known. * @returns current-generation comparison JSONL without modifying the source file. */ -export function prepareSessionSnapshotFixtureForComparison( - text: string, - sourcePath?: string, -): string { - const parsed = parseSessionFixture(text, sourcePath) +export function prepareSessionSnapshotFixtureForComparison(text: string): string { + const parsed = parseSessionFixture(text) return encodeCurrentSessionSnapshotFixture(text, parsed) } @@ -1034,7 +950,7 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { /** Read a primary JSONL unless a whole-script sidecar intentionally occupies the same path. */ function readPrimaryFixture(config: ReplayConfig): ParsedSessionFixture | undefined { if (!existsSync(config.file) || config.file === config.overrideFile) return undefined - return parseSessionFixture(readFileSync(config.file, 'utf8'), config.file) + return parseSessionFixture(readFileSync(config.file, 'utf8')) } /** Resolve an override or derive from one already validated and migrated fixture. */ @@ -1097,7 +1013,7 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] { throw new Error(`llm-replay: child fixture not found: ${childFile} — re-record the scenario`) } const text = readFileSync(childFile, 'utf8') - const fixture = parseSessionFixture(text, childFile) + const fixture = parseSessionFixture(text) // Derive the child's script from its own events only — events AT OR after the seed // boundary. const ownEvents = fixture.events.slice(fixture.inheritedEventCount) diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts index 63ddbd7de8..5abc4f4088 100644 --- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts @@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format-catalog' import { CompactionId } from '@deepseek-ai/dsh-compaction' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import LlmRuntime, { @@ -31,14 +30,12 @@ import { loadSessionScripts, migrateSessionSnapshotFixture, name, - parseSessionLogForReplay, parseSessionHeader, parseSessionLog, prepareSessionEventNotificationsForComparison, prepareSessionSnapshotFixtureForComparison, resolveScriptedEntry, } from '../src/index.ts' -import { ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES } from '../src/alpha-refusal-fixtures.ts' declare module '@deepseek-ai/dsh-deepseek-llm-api-extensions/types' { interface DeepSeekLlmApiExtensionMap { @@ -386,27 +383,6 @@ describe('parseSessionLog', () => { .toThrow('session snapshot line 2 contains invalid JSON') }) - it.each(ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES)( - 'keeps pathless parsing strict for $repoRelativePath', - (fixture) => { - const source = readFileSync(fixture.path, 'utf8') - - expect(() => parseSessionLog(source)).toThrow(SessionFormatUnsupportedMigrationError) - }, - ) - - it.each(ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES)( - 'permits replay extraction only for the exact allowlisted path $repoRelativePath', - (fixture) => { - const source = readFileSync(fixture.path, 'utf8') - const lookalike = resolve(dir, fixture.repoRelativePath) - - expect(parseSessionLogForReplay(source, fixture.path).length).toBeGreaterThan(0) - expect(() => parseSessionLogForReplay(source, lookalike)) - .toThrow(SessionFormatUnsupportedMigrationError) - }, - ) - it('skips the header line and parses each event', () => { const events: SessionEvent[] = [{ type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }] expect(parseSessionLog(sessionJsonl(events))).toEqual(events) @@ -707,37 +683,14 @@ describe('prepareSessionSnapshotFixtureForComparison', () => { expect(prepared.endsWith('\n')).toBe(false) }) - it.each(ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES)( - 'structurally prepares the exact alpha refusal $repoRelativePath without changing its source', - (fixture) => { - const source = readFileSync(fixture.path, 'utf8') + it('migrates a valid v0 fixture through both adjacent edges', () => { + const source = readFileSync(resolve(import.meta.dirname, '../../../../snapshots/web/schedule-catalog/session.jsonl'), 'utf8') - const prepared = prepareSessionSnapshotFixtureForComparison(source, fixture.path) - expect(JSON.parse(source.split('\n')[0]!)).toMatchObject({ version: 0 }) - expect(JSON.parse(prepared.split('\n')[0]!)).toMatchObject({ version: SESSION_FORMAT_VERSION }) - expect(prepared).not.toContain('"type":"assistant/chunk"') - expect(readFileSync(fixture.path, 'utf8')).toBe(source) - if (fixture.repoRelativePath.includes('agent-instructions')) { - expect(prepared).toContain('"plugin":"alpha-comparison-compact"') - expect(prepared).toContain('"stream":') - } else { - expect(prepared).toContain('"source":{"kind":"fallback"}') - } - }, - ) + const prepared = prepareSessionSnapshotFixtureForComparison(source) - it('repairs the paired current agent-instructions result through the same closed source identity', () => { - const fixture = ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES[0]! - const source = readFileSync(fixture.path, 'utf8') - const prepared = prepareSessionSnapshotFixtureForComparison(source, fixture.path) - const invalidCurrent = prepared.replace( - '"plugin":"alpha-comparison-compact"', - '"plugin":"compact","compactionId":"workspace-context-fixture"', - ) - - expect(prepareSessionSnapshotFixtureForComparison(invalidCurrent, fixture.path)).toBe(prepared) - expect(() => prepareSessionSnapshotFixtureForComparison(invalidCurrent, resolve(dir, fixture.repoRelativePath))) - .toThrow(/compaction checkpoint.*has no matching compaction\/start/) + expect(JSON.parse(source.split('\n')[0]!)).toMatchObject({ version: 0 }) + expect(JSON.parse(prepared.split('\n')[0]!)).toMatchObject({ version: SESSION_FORMAT_VERSION }) + expect(parseSessionLog(prepared)).toEqual(parseSessionLog(source)) }) }) diff --git a/packages/test-support/llm-replay/tests/session-format-corpus.spec.ts b/packages/test-support/llm-replay/tests/session-format-corpus.spec.ts index d2b023f27b..0dcd9add29 100644 --- a/packages/test-support/llm-replay/tests/session-format-corpus.spec.ts +++ b/packages/test-support/llm-replay/tests/session-format-corpus.spec.ts @@ -1,17 +1,11 @@ import { readFileSync, readdirSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format-catalog' import { parseSessionLog } from '../src/index.ts' -import { ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES } from '../src/alpha-refusal-fixtures.ts' const repoRoot = resolve(import.meta.dirname, '../../../..') const excludedDirectories = new Set(['dist', 'lib', 'node_modules']) -const alphaRefusalManifest: ReadonlyMap = new Map( - ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES.map(fixture => [fixture.repoRelativePath, fixture.expectedMessage]), -) - function committedSessionFixtures(directory: string): string[] { const files: string[] = [] for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -44,8 +38,7 @@ function filenameFormatVersion(path: string): number { } describe('committed Session format corpus', () => { - it('migrates every versioned fixture or matches one exact alpha refusal', () => { - const seenRefusals = new Set() + it('restores every versioned fixture through the current format catalog', () => { const files = ['snapshots', 'packages', 'scripts/snapshots/python-sdk-single-exe'] .flatMap(root => committedSessionFixtures(join(repoRoot, root))) .sort() @@ -58,23 +51,7 @@ describe('committed Session format corpus', () => { version?: unknown } expect(header.version, `${key}: filename/header Session generation`).toBe(filenameFormatVersion(file)) - let failure: unknown - try { - parseSessionLog(source) - } catch (error: unknown) { - failure = error - } - const expected = alphaRefusalManifest.get(key) - if (expected === undefined) { - expect(failure, `${key}: unclassified Session format refusal`).toBeUndefined() - } else { - expect(failure, `${key}: refusal no longer occurs`) - .toBeInstanceOf(SessionFormatUnsupportedMigrationError) - expect((failure as Error).message).toBe(expected) - seenRefusals.add(key) - } + expect(() => parseSessionLog(source), `${key}: current-format restoration`).not.toThrow() } - - expect([...seenRefusals].sort()).toEqual([...alphaRefusalManifest.keys()].sort()) }) }) diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index b4b63d1249..0bfa775b00 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/session-snapshot/README.md -README.md: 9ec2df0edf1b243e66836de7da3283f9a85bf2a4 -README.zh.md: 625df339bf706dad6c41534da8edd78824fe0a87 +README.md: 1cdac5675df274739ce26871488c593831f8d1e4 +README.zh.md: 9170fbb776a007ab33854f892ae5a9cf77d6771c diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 9ec2df0edf..1cdac5675d 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -70,7 +70,7 @@ defineAcpSnapshotSuite({ Each recorded-session directory carries a closed `snapshot.yml` manifest plus canonical parent and contiguous child roles. Parent filenames are `session[.vN].jsonl`; children are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions use lowercase `.vN`, and every filename agrees with its header. A role may retain older generations, but the harness selects the numerically highest one. The manifest names the scenario, shipped profile, composition/header class, recording source, and only the replay, platform, permission, environment, workspace, or input facts the completed Session cannot reconstruct. The adapter registers expected-output, Session-log, and optional `workspace.expected/` comparisons; guards reject orphan directories, missing roles, noncanonical names, absolute paths, malformed manifests, and platform-specific separators. -`normalizeSessionSnapshot` retains the complete Session header and event payloads but omits top-level `seq`/`time` envelopes from committed fixtures after normalizing paths and scrubbing request headers; it also normalizes embedded stream clocks and historical packed-row `seq0`/`time0` envelopes. Replay synthesizes the top-level envelopes in memory, while runtime persistence continues to write complete logs. Multi-session comparison restores every selected persisted or projected fixture through the current build-static Session format catalog before identity redaction and normalization, so retained v0/v1 replay input and fresh `session.v2.jsonl` writer output compare as one v2 logical Session without rewriting or renaming historical files. Expected logs may carry aligned `sourcePaths`; only the two exact alpha-refusal paths receive header-generation normalization after their pinned catalog refusal, while harvested current logs remain pathless and strict. Versionless protocol-adapter unit fixtures remain outside the released Session format corpus. Current v2 fixtures use one row per event; retained v0/v1 fixtures may use canonical packed rows. The [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) (`pnpm run migrate:packed-session-fixtures`) rewrites older historical layouts, and its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns its deletion. +`normalizeSessionSnapshot` retains the complete Session header and event payloads but omits top-level `seq`/`time` envelopes from committed fixtures after normalizing paths and scrubbing request headers; it also normalizes embedded stream clocks and historical packed-row `seq0`/`time0` envelopes. Replay synthesizes the top-level envelopes in memory, while runtime persistence continues to write complete logs. Multi-session comparison restores every selected persisted or projected fixture through the current build-static Session format catalog before identity redaction and normalization, so retained v0/v1 replay input and fresh `session.v2.jsonl` writer output compare as one v2 logical Session without rewriting or renaming historical files. Expected and harvested logs use the same strict restoration path; source filenames cannot alter format validation. Versionless protocol-adapter unit fixtures remain outside the released Session format corpus. Current v2 fixtures use one row per event; retained v0/v1 fixtures may use canonical packed rows. The [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) (`pnpm run migrate:packed-session-fixtures`) rewrites older historical layouts, and its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns its deletion. ### Record, replay, and refresh diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index 625df339bf..9170fbb776 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -70,7 +70,7 @@ defineAcpSnapshotSuite({ 每个已记录 Session 目录携带封闭的 `snapshot.yml` manifest,以及规范 parent 与连续 child 角色。parent 文件名是 `session[.vN].jsonl`;child 是 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本使用小写 `.vN`,且每个文件名与其 header 一致。一个角色可以保留旧 generation,但 harness 会选择数值最高的一项。manifest 指名场景、随附 profile、组合/header 类别、录制来源,以及已完成 Session 无法重建的 replay、平台、权限、环境、workspace 或输入事实。适配器注册预期输出、Session 日志与可选 `workspace.expected/` 比较;保护会拒绝遗留目录、缺失角色、非规范名称、绝对路径、malformed manifest 与平台专用分隔符。 -`normalizeSessionSnapshot` 在规范化路径并清理 request header 后,会保留完整 Session header 与事件 payload,但从已提交 fixture 中省略顶层 `seq`/`time` envelope;它还会规范化嵌入式 stream clock 与历史 packed-row 的 `seq0`/`time0` envelope。Replay 只在内存中合成顶层 envelope,而运行时持久化仍写入完整日志。多 Session 比较会先通过当前构建期静态 Session 格式目录恢复每个选定的持久化或投影 fixture,再进行身份脱敏与规范化,因此保留的 v0/v1 replay 输入与新生成的 `session.v2.jsonl` writer 输出会作为同一个 v2 logical Session 比较,且不会重写或重命名历史文件。预期日志可以携带对齐的 `sourcePaths`;只有两项精确 alpha 拒绝路径会在 catalog 返回固定拒绝后规范化 header generation,而收集到的当前日志仍无路径且保持严格。无版本的协议适配器单元测试 fixture 不属于已发布 Session 格式语料。当前 v2 fixture 每个事件占一行;保留的 v0/v1 fixture 可以使用规范 packed row。[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写更旧的历史布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。 +`normalizeSessionSnapshot` 在规范化路径并清理 request header 后,会保留完整 Session header 与事件 payload,但从已提交 fixture 中省略顶层 `seq`/`time` envelope;它还会规范化嵌入式 stream clock 与历史 packed-row 的 `seq0`/`time0` envelope。Replay 只在内存中合成顶层 envelope,而运行时持久化仍写入完整日志。多 Session 比较会先通过当前构建期静态 Session 格式目录恢复每个选定的持久化或投影 fixture,再进行身份脱敏与规范化,因此保留的 v0/v1 replay 输入与新生成的 `session.v2.jsonl` writer 输出会作为同一个 v2 logical Session 比较,且不会重写或重命名历史文件。预期日志与收集日志使用同一条严格恢复路径;来源文件名不能改变格式校验。无版本的协议适配器单元测试 fixture 不属于已发布 Session 格式语料。当前 v2 fixture 每个事件占一行;保留的 v0/v1 fixture 可以使用规范 packed row。[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写更旧的历史布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。 ### 录制、回放与刷新 diff --git a/packages/test-support/session-snapshot/src/index.ts b/packages/test-support/session-snapshot/src/index.ts index 4fc0c590d9..ae3289aa6c 100644 --- a/packages/test-support/session-snapshot/src/index.ts +++ b/packages/test-support/session-snapshot/src/index.ts @@ -52,7 +52,6 @@ export { type CwdPathMode, type NormalizeContext, type NormalizeOptions, - type NormalizeSessionSnapshotsOptions, } from './normalize.ts' export { parseSnapshotManifest, diff --git a/packages/test-support/session-snapshot/src/normalize.ts b/packages/test-support/session-snapshot/src/normalize.ts index 23ef50727c..8b76e63df8 100644 --- a/packages/test-support/session-snapshot/src/normalize.ts +++ b/packages/test-support/session-snapshot/src/normalize.ts @@ -104,12 +104,6 @@ export interface NormalizeOptions { identityMode?: 'legacy' | 'preserve' } -/** Multi-Session comparison controls, including optional committed source identities. */ -export interface NormalizeSessionSnapshotsOptions extends Omit { - /** Primary-first source paths; exact alpha-refusal fixtures receive replay-only comparison policy. */ - sourcePaths?: readonly (string | undefined)[] -} - /** Return every known spelling of the generated cwd, most specific first. */ function cwdSpellings(ctx: NormalizeContext): string[] { const spellings = [...new Set([ctx.cwd, ...ctx.cwdAliases ?? []])] @@ -439,21 +433,17 @@ export function normalizeSessionSnapshot( export function normalizeSessionSnapshots( rawLogs: readonly string[], ctx: NormalizeContext, - options: NormalizeSessionSnapshotsOptions = {}, + options: Omit = {}, ): string[] { - const { sourcePaths, ...normalizeOptions } = options - if (sourcePaths !== undefined && sourcePaths.length !== rawLogs.length) { - throw new Error('Session snapshot source path count must match its log count') - } - const currentLogs = rawLogs.map((log, index) => hasSessionFormatVersion(log) - ? prepareSessionSnapshotFixtureForComparison(log, sourcePaths?.[index]) + const currentLogs = rawLogs.map(log => hasSessionFormatVersion(log) + ? prepareSessionSnapshotFixtureForComparison(log) : log) const comparableLogs = currentLogs.map(normalizeSessionFormatProvenance) return redactSessionSnapshotIds(comparableLogs).map(log => projectSessionSnapshot( scrubSessionSnapshot(normalizeSessionLog( log, { ...ctx, sessionIds: [] }, - { ...normalizeOptions, identityMode: 'preserve' }, + { ...options, identityMode: 'preserve' }, )), )) } diff --git a/packages/test-support/session-snapshot/src/session-files.ts b/packages/test-support/session-snapshot/src/session-files.ts index 0199f8c553..a7a080c657 100644 --- a/packages/test-support/session-snapshot/src/session-files.ts +++ b/packages/test-support/session-snapshot/src/session-files.ts @@ -156,7 +156,10 @@ export function assertSessionFixtureVersion(name: string, content: string): numb if (projected !== null && typeof projected === 'object' && !Array.isArray(projected) && (projected as { type?: unknown }).type === 'session' && !Object.hasOwn(projected, 'version')) { - return fixture.version + if (fixture.version !== 0) { + throw new Error(`${name}: a versionless projected Session header is format v0`) + } + return 0 } } const headerVersion = sessionHeaderVersion(content, name) diff --git a/packages/test-support/session-snapshot/src/suite.ts b/packages/test-support/session-snapshot/src/suite.ts index 125edba2f8..23a24364c3 100644 --- a/packages/test-support/session-snapshot/src/suite.ts +++ b/packages/test-support/session-snapshot/src/suite.ts @@ -17,7 +17,7 @@ * @module @deepseek-ai/dsh-session-snapshot/suite */ -import { readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { readFile, readdir, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' import { isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' @@ -28,7 +28,6 @@ import { redactSessionSnapshotIds } from './identity.ts' import { captureExpectedWorkspaceSnapshot } from './workspace.ts' import { assertSessionFixtureVersion, - parseSessionFixtureName, sessionFixtureName, sessionFixtureNames, sessionHeaderVersion, @@ -1276,12 +1275,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const outputFixtures = redactSessionSnapshotIds(stabilizeFixtureMessageIds(freshFixtures, existingFixtures)) await Promise.all(outputFixtures.map((fixture, index) => writeFile(join(dir, outputFixtureFiles[index] as string), fixture))) - const retiredFixtures = (await readdir(dir)) - .map(parseSessionFixtureName) - .filter((fixture): fixture is NonNullable => ( - fixture !== undefined && fixture.index >= outputFixtureFiles.length - )) - await Promise.all(retiredFixtures.map(fixture => rm(join(dir, fixture.name)))) fixtureFiles = outputFixtureFiles if (scenario.pinsHeader === true) { const primary = result.sessionLogs[0] as HarvestedLog @@ -1361,11 +1354,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { sessionIds: fixtureContexts.flatMap(context => context.sessionIds), cwd: (fixtureContexts[0] as NormalizeContext).cwd, } - const sourcePaths = fixtureFiles.map(file => join(dir, file)) - const actualSnapshots = normalizeSessionSnapshots(harvested, ctx, { sourcePaths }) - const expectedSnapshots = normalizeSessionSnapshots(fixtures, fixtureCtx, { - sourcePaths, - }) + const actualSnapshots = normalizeSessionSnapshots(harvested, ctx) + const expectedSnapshots = normalizeSessionSnapshots(fixtures, fixtureCtx) for (const [index, actual] of actualSnapshots.entries()) { expect(actual, `${fixtureFiles[index]} mismatch`).toEqual(expectedSnapshots[index]) } diff --git a/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts b/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts index 6830cbaf4b..c9f40f7873 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts @@ -24,13 +24,28 @@ export function apply(ctx: Context): void { && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) if (baseline === undefined) throw new Error('workspace baseline missing before snapshot compaction') + const openTurn = agent.session.snapshotEvents().findLast(event => event.type === 'turn/start') + if (openTurn?.type !== 'turn/start') throw new Error('workspace snapshot compaction has no open turn') + const compactionId = CompactionId('workspace-context-fixture') + const content = [{ type: 'text' as const, text: 'Earlier context was compacted for this snapshot.' }] + agent.session.append('compaction/start', { compactionId, turn: openTurn.data.turn }) + agent.session.append('compaction/summary', { + compactionId, + summary: content, + shadowedRange: { start: baseline.seq, end: baseline.seq }, + shadowedSeqs: [baseline.seq], + shadowedTokenCount: 1, + provider: 'snapshot', + model: 'snapshot', + }) agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Earlier context was compacted for this snapshot.' }], - source: compactCheckpointSource(CompactionId('workspace-context-fixture')), + content, + source: compactCheckpointSource(compactionId), }), { surfaceOp: { op: 'replace', start: baseline.seq, end: baseline.seq }, sourceEventSeqs: [baseline.seq], }) + agent.session.append('compaction/end', { compactionId, turn: openTurn.data.turn }) return downstream }) } diff --git a/packages/test-support/session-snapshot/tests/normalize.spec.ts b/packages/test-support/session-snapshot/tests/normalize.spec.ts index c68f8b0a37..9d9e0164ff 100644 --- a/packages/test-support/session-snapshot/tests/normalize.spec.ts +++ b/packages/test-support/session-snapshot/tests/normalize.spec.ts @@ -1,6 +1,4 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES } from '@deepseek-ai/dsh-llm-replay' import { type NormalizeContext, extractSnapshotSpillPaths, @@ -603,16 +601,6 @@ describe('normalizeSessionSnapshot', () => { ].join('\n')]) }) - it('rejects source paths that cannot identify every snapshot', () => { - const raw = `${JSON.stringify({ type: 'session', version: 1 })}\n` - - expect(() => normalizeSessionSnapshots( - [raw], - { sessionIds: [], cwd: '/unused' }, - { sourcePaths: [] }, - )).toThrow('Session snapshot source path count must match its log count') - }) - it('normalizes an already-projected snapshot without a released-format field', () => { const raw = `${JSON.stringify({ type: 'session', @@ -636,22 +624,6 @@ describe('normalizeSessionSnapshot', () => { .toThrow('session snapshot must start with a session header') }) - it.each(ALPHA_SESSION_FORMAT_REFUSAL_FIXTURES)( - 'generation-normalizes the exact replay-only refusal $repoRelativePath without rewriting it', - (fixture) => { - const source = readFileSync(fixture.path, 'utf8') - - const [normalized] = normalizeSessionSnapshots( - [source], - { sessionIds: [], cwd: '/unused' }, - { sourcePaths: [fixture.path] }, - ) - - expect(JSON.parse(normalized?.split('\n')[0] as string)).not.toHaveProperty('version') - expect(readFileSync(fixture.path, 'utf8')).toBe(source) - }, - ) - it('compares migrated and fresh delivery watermarks without changing their raw generation identity', () => { const id = '11111111-2222-3333-4444-555555555555' const session = (version: 0 | 1, sessionFormatVersion?: number): string => [ diff --git a/packages/test-support/session-snapshot/tests/suite.spec.ts b/packages/test-support/session-snapshot/tests/suite.spec.ts index 46645ec665..0236bc5b6c 100644 --- a/packages/test-support/session-snapshot/tests/suite.spec.ts +++ b/packages/test-support/session-snapshot/tests/suite.spec.ts @@ -1,4 +1,4 @@ -import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -214,12 +214,12 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { }) describe('defineAcpSnapshotSuite: record inventory write-back', () => { - it('creates a missing primary fixture and removes generations for a retired child role', () => { + it('creates a missing primary fixture and preserves generations for a retired child role', () => { const fixture = readFileSync(join(recordDir, 'rec-pin', 'session.v2.jsonl'), 'utf8') expect(fixture).toContain('"type":"session"') expect(fixture).toContain('"cwd":"{{cwd}}"') if (!BOOTSTRAP) { - expect(existsSync(join(recordDir, 'rec-child', 'session.2.jsonl'))).toBe(false) + expect(readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toBe(retiredChildFixture) } expect(readFileSync(join(recordDir, 'rec-child', 'tool-schemas.1.expected.json'), 'utf8')) .toContain('"name": "t1"') diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 72c69b9ff2..4930c80174 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1911,6 +1911,11 @@ "symbol": "AgentStatus", "source": "packages/core/agent/src/runtime-types.ts" }, + { + "doc": "docs/subsystems/core.md", + "symbol": "AssistantStreamFrame", + "source": "packages/core/agent/src/runtime-types.ts" + }, { "doc": "docs/subsystems/core.md", "symbol": "AgentOptions", diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index dd25dd9444..9d9ddd7c55 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -808,12 +808,8 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { // Persisted transcripts match the committed fixtures. const expectedContext = contextOfContents(expectedContents) - const actualSnapshots = normalizeSessionSnapshots(ordered.map(log => log.content), actualContext, { - sourcePaths: files, - }) - const expectedSnapshots = normalizeSessionSnapshots(expectedContents, expectedContext, { - sourcePaths: files, - }) + const actualSnapshots = normalizeSessionSnapshots(ordered.map(log => log.content), actualContext) + const expectedSnapshots = normalizeSessionSnapshots(expectedContents, expectedContext) expect(actualSnapshots.map(records), `${scenario.name}: sessions`).toEqual(expectedSnapshots.map(records)) await verifyHeaders(scenario, ordered, actualContext, assertions.dshSdkChild?.agentConfig) diff --git a/snapshots/session/agent-instructions/session.jsonl b/snapshots/session/agent-instructions/session.jsonl index 66216297a5..46a0b027e9 100644 --- a/snapshots/session/agent-instructions/session.jsonl +++ b/snapshots/session/agent-instructions/session.jsonl @@ -19,7 +19,10 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} +{"type":"compaction/start","data":{"compactionId":"workspace-context-fixture","turn":1}} +{"type":"compaction/summary","data":{"compactionId":"workspace-context-fixture","summary":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"shadowedRange":{"start":8,"end":8},"shadowedSeqs":[8],"shadowedTokenCount":1,"provider":"snapshot","model":"snapshot"}} {"type":"user/message","data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact","compactionId":"workspace-context-fixture"},"role":"user","id":"{{message:5}}"},"sourceEventSeqs":[8],"surfaceOp":{"op":"replace","start":8,"end":8}} +{"type":"compaction/end","data":{"compactionId":"workspace-context-fixture","turn":1}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:6}}"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"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\nRoot snapshot instruction.\n\n"},{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"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":"2e18766c26603608f321508caae00ea8f4434d59"},{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"{{message:7}}"}]}} @@ -32,9 +35,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[31,32,33,34,35],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:9}}"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:9}}"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[37],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"{{message:10}}"}]}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} @@ -45,6 +48,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[44,45,46,47,48],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 3e52468921..0e9a8a3f7d 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -790,13 +790,8 @@ describe('headless recorded-session snapshots', () => { expect(actualLogs, `${scenario.name}: persisted session count`).toHaveLength(fixtures.length) const actualContext = contextOf(actualLogs.map(log => log.content)) const fixtureContext = contextOf(fixtures) - const sourcePaths = fixtureFiles.map(file => join(scenario.dir, file)) - const actualSnapshots = normalizeSessionSnapshots(actualLogs.map(log => log.content), actualContext, { - sourcePaths, - }) - const expectedSnapshots = normalizeSessionSnapshots(fixtures, fixtureContext, { - sourcePaths, - }) + const actualSnapshots = normalizeSessionSnapshots(actualLogs.map(log => log.content), actualContext) + const expectedSnapshots = normalizeSessionSnapshots(fixtures, fixtureContext) for (const [index, actual] of actualSnapshots.entries()) { expect(actual, `${scenario.name}: session ${index}`).toBe(expectedSnapshots[index]) } diff --git a/snapshots/web/schedule-catalog/session.jsonl b/snapshots/web/schedule-catalog/session.jsonl index 540fddeb49..cf17290462 100644 --- a/snapshots/web/schedule-catalog/session.jsonl +++ b/snapshots/web/schedule-catalog/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"{{session:1}}","createdAt":1787644800000,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"turn/start","data":{"turn":1}} {"type":"user/message","data":{"role":"user","content":[{"type":"text","text":"Show the active reminders."}],"source":{"kind":"user"},"id":"{{message:1}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Active schedule catalog","messageSeqs":[1],"source":{"kind":"user"}}} +{"type":"session/title","data":{"title":"Active schedule catalog","messageSeqs":[],"source":{"kind":"user"}}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"The active reminders are available in the session header."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"{{message:2}}"},"usage":{"inputTokens":4,"outputTokens":10}},"surfaceOp":"append"} {"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-after","kind":"after","prompt":"Review overdue deployment","afterSeconds":60,"scheduledAt":"2099-08-25T11:59:00.000Z"}}} diff --git a/vitest.config.ts b/vitest.config.ts index 39b944664b..fd018fc825 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -221,8 +221,9 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx', 'packages/client/ui-renderer/src/client/*', // Session object internals retain the runtime GUI debt exemption; the - // new Controller entry, transport, Agent scope, and adapters stay gated. - 'packages/api/session-controller/src/client/sessions/*', + // assistant-stream reconciler, Controller entry, transport, Agent scope, + // and adapters stay gated. + 'packages/api/session-controller/src/client/sessions/!(assistant-stream).ts', 'packages/api/session-controller/src/client/ordered-baseline.ts', 'packages/api/session-controller/src/client/time-zone.ts', // Keep the browser conversation tree under its existing GUI debt