From 8dcfe5c4065e4f99511c3886cce68d147de8efbb Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sat, 8 Aug 2026 02:16:17 +0800
Subject: [PATCH 1/4] fix(replay): mark local compaction calls
---
...ction-summary-prefix-cache-reuse.i18n.yaml | 4 +-
...1-compaction-summary-prefix-cache-reuse.md | 4 +-
...ompaction-summary-prefix-cache-reuse.zh.md | 4 +-
...06-18-compaction-capability-seam.i18n.yaml | 4 +-
.../2026-06-18-compaction-capability-seam.md | 6 +-
...026-06-18-compaction-capability-seam.zh.md | 6 +-
.../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 +-
.../testing/2026-06-19-acp-snapshot-tests.md | 7 ++-
.../2026-06-19-acp-snapshot-tests.zh.md | 7 ++-
docs/config-catalog.md | 2 +-
.../core-data-structures/compaction.i18n.yaml | 4 +-
docs/core-data-structures/compaction.md | 2 +-
docs/core-data-structures/compaction.zh.md | 2 +-
docs/persistence-catalog.md | 14 ++++-
.../compaction.cordis.snapshot.yml | 1 -
.../compaction-recovery/session.jsonl | 2 +-
.../stream-json.expected.jsonl | 2 +-
.../compact/compact-basic/README.i18n.yaml | 4 +-
packages/compact/compact-basic/README.md | 2 +-
packages/compact/compact-basic/README.zh.md | 2 +-
packages/compact/compact-basic/src/region.ts | 2 +
.../compact/compact-basic/src/summarizer.ts | 11 +++-
.../compact-basic/tests/compact-basic.spec.ts | 3 +
packages/compact/compact/src/types.ts | 10 ++-
packages/support/llm-replay/README.i18n.yaml | 4 +-
packages/support/llm-replay/README.md | 6 +-
packages/support/llm-replay/README.zh.md | 6 +-
packages/support/llm-replay/src/index.ts | 10 ++-
.../llm-replay/tests/llm-replay.spec.ts | 62 +++++++++++++++++++
29 files changed, 147 insertions(+), 50 deletions(-)
diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml
index b8ab8490d0..2ede6629d7 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.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-07-21-compaction-summary-prefix-cache-reuse.md
-2026-07-21-compaction-summary-prefix-cache-reuse.md: d05d25cfa7c3984ce0ce75c38068a91a0e07dfe8
-2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: f31c33e680b3db60a6fb758a522addca63224fea
+2026-07-21-compaction-summary-prefix-cache-reuse.md: fb6524846f837b783423b3ed0e043cd4e1f7849d
+2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: e6072313b7089b9dda14c298fcb94db786220501
diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
index d05d25cfa7..fb6524846f 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
@@ -29,7 +29,7 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th
- **Keep the summarizer system prompt but reuse the rest** — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache.
- **Send only the shadowed region without the `system`/`tools` head** — rejected: a differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs.
- **Omit `tools` from the summarization request** (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse.
-- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — out of scope here; the replay gap predates this change and is tracked in the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md).
+- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — rejected: the durable `compact/summary` event records the successful local call's position and complete output, while its explicit call marker prevents replay from treating template or remote output as a local stream.
## Consequences
@@ -42,4 +42,4 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th
- **Unit:** `compact-basic.spec.ts` asserts the auxiliary call forwards `system`/`tools`/leading messages and appends the compaction instruction as the final message, and that `compactRegion` replays the latest routed header prefix. Existing content assertions read the summarizer input through the replayed messages rather than a transcript string.
- **Loop:** `compact-loop-repro.spec.ts` classifies the summarization request by the compaction instruction in its trailing user message, and the overflow-recovery tests continue to pin conversation-vs-summary request counts across the real loop.
-- **Snapshot gap unchanged:** the summarization call still emits no `assistant/chunk` events, so it remains outside keyless replay; the pre-existing gap is owned by the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md).
+- **Snapshot:** keyless replay reconstructs one canonical successful stream from a marked `compact/summary`; the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md) owns the durable marker contract.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md
index f31c33e680..e6072313b7 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md
@@ -29,7 +29,7 @@ Status: implemented
- **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。
- **只发送被遮蔽区域而不带 `system`/`tools` 头部**——否决:头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。
- **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。
-- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。
+- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——否决:持久的 `compact/summary` 事件会记录成功本地调用的位置和完整输出,而显式调用标记可防止回放把模板或远程输出当作本地流。
## 后果
@@ -42,4 +42,4 @@ Status: implemented
- **单元:** `compact-basic.spec.ts` 断言辅助调用转发 `system`/`tools`/前导消息,并把压缩指令作为最后一条消息追加,且 `compactRegion` 回放最新的已路由 header 前缀。现有的内容断言通过回放的消息而非 transcript 字符串来读取摘要器输入。
- **循环:** `compact-loop-repro.spec.ts` 依据摘要请求尾部 user 消息中的压缩指令对其分类,溢出恢复测试则继续在真实循环中固定对话请求与摘要请求的数量。
-- **快照缺口未变:** 摘要调用仍然不发出 `assistant/chunk` 事件,因此它仍处于无密钥回放之外;这一既有缺口归 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 所有。
+- **快照:** 无密钥回放会从带标记的 `compact/summary` 重建一条规范成功流;[compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 负责持久标记契约。
diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml
index 9e195a4b1d..1917b118da 100644
--- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
-2026-06-18-compaction-capability-seam.md: 8dcbe74429a620027a570124383442b969c12196
-2026-06-18-compaction-capability-seam.zh.md: 27b63f29c2e6f35637185b47c882ae42e5d41088
+2026-06-18-compaction-capability-seam.md: 390d091fd6e6f7fa1694ba78b90522b70948fe92
+2026-06-18-compaction-capability-seam.zh.md: d72793e6adde911a8623ac1c6b65df899381923e
diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
index 8dcbe74429..390d091fd6 100644
--- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
+++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
@@ -33,7 +33,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis"
An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making all three operations abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold.
-`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactNow(agent, signal)` requires an idle agent and performs one useful balanced reduction even below pressure, returning `null` without writes when none exists. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for explicit callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`.
+`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactNow(agent, signal)` requires an idle agent and performs one useful balanced reduction even below pressure, returning `null` without writes when none exists. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for explicit callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). The result carries `llmStreamCall: true` because it consumed exactly one call through this context's LLM service; a subclass sets that marker only under the same condition, since retained `rawOutput` alone does not identify the call path. The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`.
### Automatic pressure runs after successful durable step work
@@ -76,7 +76,7 @@ Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` e
```
compact/start → log-only. Acquires the lock.
[summarize older range via the backend]
-compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count.
+compact/summary → log-only. Provenance: raw summary, local-call marker, range, shadowed seqs, token count.
user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }.
THE surface mutation (framed summary).
deriveMessages() renders it as a user-role message.
@@ -131,4 +131,4 @@ The lifecycle boundary makes crash state unambiguous:
- **Loop:** Tests pin pre-step after the preceding `step/end` and before the next `step/start`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition.
- **Manual:** Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key.
- **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task.
-- **Snapshot gap:** The summarization call is session-associated and logs `compact/summary`, but ordinary transcript replay does not derive its auxiliary response; keyless assembled coverage therefore needs an explicit replay override.
+- **Snapshot:** The assembled context-overflow scenario derives the auxiliary call from `compact/summary` only when `llmStreamCall: true` proves that the local LLM service consumed it; canonical reconstructed blocks pin the complete recovery without provider delta partitioning.
diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md
index 27b63f29c2..d72793e6ad 100644
--- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md
+++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md
@@ -33,7 +33,7 @@ Status: implemented
早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法。这会将契约重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将三个操作都设为抽象,把所有*怎么做*的决策放在后端,并让接口保持为*做什么*的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。
-`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactNow(agent, signal)` 要求 agent 处于 idle,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 `null`,且不写入任何内容。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。
+`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactNow(agent, signal)` 要求 agent 处于 idle,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 `null`,且不写入任何内容。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该结果携带 `llmStreamCall: true`,因为生成它时恰好通过此上下文的 LLM 服务发起了一次调用;只有满足相同条件时,子类才设置该标记,因为单有保留的 `rawOutput` 并不能判定调用路径。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。
### 成功的持久步骤工作完成后运行自动压力检查
@@ -76,7 +76,7 @@ retry → next numbered step/start ⟵ derives from the replacement surface
```
compact/start → log-only. Acquires the lock.
[summarize older range via the backend]
-compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count.
+compact/summary → log-only. Provenance: raw summary, local-call marker, range, shadowed seqs, token count.
user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }.
THE surface mutation (framed summary).
deriveMessages() renders it as a user-role message.
@@ -131,4 +131,4 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab
- **循环测试:** 测试固定 pre-step 发生在前一个 `step/end` 之后、下一个 `step/start` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。
- **手动测试:** 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。
- **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。
-- **快照缺口:** 摘要调用与会话关联并记录 `compact/summary`,但普通 transcript(文本记录)回放不会派生其辅助响应;因此,要实现无密钥的组装态覆盖,就必须显式提供回放 override。
+- **快照:** 组装后的上下文溢出场景仅在 `llmStreamCall: true` 证明本地 LLM 服务执行了辅助调用时,才从 `compact/summary` 派生该调用;规范重建的块在不固定提供方增量切分的情况下固定完整恢复过程。
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
index 202a27ed1e..e5e6b3d67e 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.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-19-acp-snapshot-tests.md
-2026-06-19-acp-snapshot-tests.md: c7b95bd68027705b99d850d596405e56eea0dfca
-2026-06-19-acp-snapshot-tests.zh.md: 43d43262684920cae5feedb5f2eb109db00f9f8c
+2026-06-19-acp-snapshot-tests.md: c6e81dc4a51083ed37ad6f837fe7dbb08c212e23
+2026-06-19-acp-snapshot-tests.zh.md: 5a579c3373442dc4cfc7d661ea2f2f398b804dc7
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
index c7b95bd680..c6e81dc4a5 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
@@ -24,7 +24,7 @@ Every committed session-format fixture uses the canonical packed physical layout
### Replay derives the model script from the log
-`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` groups recorded chunks by `(turn, step)` and serves one group per model call. The loop makes one stream call per step, so the grouping is exact and includes error finish chunks without special handling.
+`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` splits recorded `assistant/chunk` events at terminal `finish` chunks and uses `(turn, step)` changes to reject an unterminated prior call. A `compact/summary` with `llmStreamCall: true` contributes one call at its durable log position: replay reconstructs canonical block boundaries from `rawOutput`, retains recorded usage when present, and supplies a terminal `stop`. The marker distinguishes that local call from template or remote summaries whose retained `rawOutput` did not consume this context's adapter.
### The in-memory replay entry honors the full LLM contract
@@ -36,7 +36,7 @@ Every committed session-format fixture uses the canonical packed physical layout
| { kind: 'hang' }
```
-Logs derive chunk entries. Pre-stream throws and hangs have no reconstructable chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons.
+Logs derive chunk entries from finished assistant streams and explicitly marked compaction calls. Pre-stream throws, hangs, and external summarizer calls have no reconstructable local chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons or provider output alone.
### Positional replay, one in-flight stream
@@ -74,12 +74,13 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log
## Alternatives considered
- **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral expected output.
+- **A compulsory replay override for every compaction summary** — rejected: the durable summary event already fixes a successful local call's position, complete output, and optional usage. An explicit local-call marker preserves that single-source fixture without inventing a call for template or remote summarizers.
- **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test.
- **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam.
- **Copying both request-header sidecars beside every class pin** — rejected: prompt and tool-schema composition vary independently, so a change to one shared component would churn byte-identical files across unrelated class pins. Explicit per-component sources retain one structural pin per class without duplicating content.
## Consequences
-The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here until it can move to a transport-neutral headless suite without losing coverage.
+The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition, including an assembled context-overflow recovery whose marked compaction summary supplies the auxiliary call. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here until it can move to a transport-neutral headless suite without losing coverage.
This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP.
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
index 43d4326268..5a579c3373 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
@@ -24,7 +24,7 @@ Status: implemented
### 回放从日志推导模型脚本
-`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的分片分组,每次模型调用服务一组。agent loop(智能体循环)每个步骤发起一次流调用,因此分组精确对应,错误结束分片也无需特殊处理。
+`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 在终止的 `finish` 分片处切分已记录的 `assistant/chunk` 事件,并用 `(turn, step)` 变化拒绝前一条未终止的调用。携带 `llmStreamCall: true` 的 `compact/summary` 会在其持久日志位置贡献一次调用:回放根据 `rawOutput` 重建规范块边界,保留已记录的 usage(如有),并提供终止的 `stop`。该标记将这次本地调用与模板摘要或远程摘要区分开;后两者即使保留了 `rawOutput`,也未使用此上下文的适配器。
### 内存中的回放条目遵守完整的 LLM 契约
@@ -36,7 +36,7 @@ Status: implemented
| { kind: 'hang' }
```
-日志推导出分片条目。流开始前的抛出和挂起没有可重建的分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因推断适配器行为。
+日志从已结束的 assistant 流和显式标记的压缩(compaction)调用推导分片条目。流开始前的抛出、挂起和外部摘要器调用没有可重建的本地分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因或单独的提供方输出推断适配器行为。
### 位置式回放,单个在途流
@@ -74,12 +74,13 @@ Status: implemented
## 曾考虑的替代方案
- **手工编写包含模型分片的 `llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。
+- **为每个压缩摘要强制提供回放 override**——否决:持久摘要事件已经固定成功本地调用的位置、完整输出与可选 usage。显式的本地调用标记保留了这份单一来源 fixture,而不会为模板摘要器或远程摘要器凭空构造调用。
- **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。
- **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。
- **在每个类别 pin 旁复制两个请求头伴随文件**:否决。提示词与工具 schema 的组合各自独立变化,因此一个共享组件发生变更,就会使不相关类别 pin 中字节完全相同的文件产生无意义改动。显式的分组件来源可在不重复内容的情况下,为每个类别保留一个结构性 pin。
## 后果
-该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,直至它能够在不损失覆盖的情况下迁移到传输无关的 headless 套件。
+该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖,其中包括一个组装后的上下文溢出恢复场景,其带标记的压缩摘要提供辅助调用。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,直至它能够在不损失覆盖的情况下迁移到传输无关的 headless 套件。
本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用回放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 4a9bafeb83..afc8cd302a 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -891,7 +891,7 @@ export interface ReplayModelConfig {
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
-Source: [`packages/support/llm-replay/src/index.ts:731`](../packages/support/llm-replay/src/index.ts)
+Source: [`packages/support/llm-replay/src/index.ts:735`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-llm-retry`
diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml
index a0c3f27f88..c5cd2a0b38 100644
--- a/docs/core-data-structures/compaction.i18n.yaml
+++ b/docs/core-data-structures/compaction.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md
-compaction.md: fe64ffe2707ab4a41f1db186965b944d525684c3
-compaction.zh.md: ea694f30dcc50ef48847ffbd5efdeed36aa234c5
+compaction.md: b17dfc03c62c0453c45ca2810ef90bdce9ae9ac7
+compaction.zh.md: b904cdd3ad5bf63e968fc22fccb32c5220ab8763
diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md
index fe64ffe270..b17dfc03c6 100644
--- a/docs/core-data-structures/compaction.md
+++ b/docs/core-data-structures/compaction.md
@@ -13,7 +13,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de
| Event | Payload | Role |
|---|---|---|
| `compact/start` | `{ turn }` | acquires the log-recorded lock; a number identifies the open automatic turn, while `null` identifies a standalone manual attempt |
-| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) |
+| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, an `llmStreamCall: true` marker when producing the result consumed exactly one call through this context's `ctx.llm.stream()`, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note); `rawOutput` alone does not identify the call path |
| `compact/end` | `{ turn, error? }` | releases the lock with the same numeric-or-null owner (`error` records an unsuccessful attempt) |
The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished.
diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md
index ea694f30dc..b904cdd3ad 100644
--- a/docs/core-data-structures/compaction.zh.md
+++ b/docs/core-data-structures/compaction.zh.md
@@ -13,7 +13,7 @@
| 事件 | 载荷 | 作用 |
|---|---|---|
| `compact/start` | `{ turn }` | 获取日志记录的锁;数字标识打开的自动轮次,`null` 标识独立手动尝试 |
-| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) |
+| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、生成结果时恰好通过此上下文的 `ctx.llm.stream()` 发起一次调用所带的 `llmStreamCall: true` 标记、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note);单有 `rawOutput` 并不能判定调用路径 |
| `compact/end` | `{ turn, error? }` | 使用相同的数字或 `null` 归属值释放锁(`error` 记录失败尝试) |
锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 48732dbbb0..808a03b6f4 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -237,7 +237,7 @@ Source: [`packages/ui/commands/src/index.ts:139`](../packages/ui/commands/src/in
'compact/end': { turn: number | null; error?: string }
```
-Source: [`packages/compact/compact/src/types.ts:54`](../packages/compact/compact/src/types.ts)
+Source: [`packages/compact/compact/src/types.ts:62`](../packages/compact/compact/src/types.ts)
#### `compact/prune` — log-only
@@ -261,7 +261,7 @@ Source: [`packages/compact/compact/src/types.ts:54`](../packages/compact/compact
}
```
-Source: [`packages/compact/compact/src/types.ts:64`](../packages/compact/compact/src/types.ts)
+Source: [`packages/compact/compact/src/types.ts:72`](../packages/compact/compact/src/types.ts)
#### `compact/start` — log-only
@@ -290,8 +290,16 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact
*/
'compact/summary': {
summary: ContentBlock[]
- /** Complete provider output before the backend's safe summary projection. */
+ /**
+ * Complete provider output before the backend's safe summary projection;
+ * this alone does not identify the call path.
+ */
rawOutput?: ContentBlock[]
+ /**
+ * Present only when producing the summary consumed exactly one call
+ * through this context's `ctx.llm.stream()`.
+ */
+ llmStreamCall?: true
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
shadowedTokenCount: number
diff --git a/examples/headless-agent/compaction.cordis.snapshot.yml b/examples/headless-agent/compaction.cordis.snapshot.yml
index 42fc5306ac..13515cf5cc 100644
--- a/examples/headless-agent/compaction.cordis.snapshot.yml
+++ b/examples/headless-agent/compaction.cordis.snapshot.yml
@@ -13,7 +13,6 @@
thresholdRatio: 0.99
retainTokens: 20
maxTokens: 32
- compactionRetries: 1
- insert:
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl
index 855e66ac14..38056019e2 100644
--- a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl
+++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl
@@ -19,7 +19,7 @@
{"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}
{"type":"compact/start","seq":19,"time":1786123401715,"data":{"turn":1}}
-{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}
+{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}
{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"6d2afb13-a37b-48d6-9ea5-fc8734127377"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}
{"type":"compact/end","seq":22,"time":1786123401725,"data":{"turn":1}}
{"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl
index 4d798adc37..62cc357cc7 100644
--- a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl
+++ b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl
@@ -18,7 +18,7 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml
index 8c8b85f323..062021a4c1 100644
--- a/packages/compact/compact-basic/README.i18n.yaml
+++ b/packages/compact/compact-basic/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/compact/compact-basic/README.md
-README.md: 0c7b009255dc2d41dc81cf2c7ff745e02ef28b9a
-README.zh.md: 4af584a059c99725882afd6206bdf9c984c7d4e3
+README.md: d1c1dfb509ae0750e1237532a829a35de5084c5e
+README.zh.md: a78887daad04d534ffed9cbd0357b76bdd908f5e
diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md
index 0c7b009255..d1c1dfb509 100644
--- a/packages/compact/compact-basic/README.md
+++ b/packages/compact/compact-basic/README.md
@@ -21,7 +21,7 @@ This backend owns the compaction policy:
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after cleanup and durability.
-The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`); the transaction preserves those fields on `compact/summary`.
+The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`); `llmStreamCall: true` means producing that result consumed exactly one call through this context's `ctx.llm.stream()`, while `rawOutput` alone does not identify the call path. The transaction preserves those fields on `compact/summary`.
## Config (`BasicCompactConfig`)
diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md
index 4af584a059..a78887daad 100644
--- a/packages/compact/compact-basic/README.zh.md
+++ b/packages/compact/compact-basic/README.zh.md
@@ -21,7 +21,7 @@
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
- **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。
-受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`);事务会在 `compact/summary` 上保留这些字段。
+受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`);`llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,而单有 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。
## 配置(`BasicCompactConfig`)
diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts
index 2132081557..67516a830d 100644
--- a/packages/compact/compact-basic/src/region.ts
+++ b/packages/compact/compact-basic/src/region.ts
@@ -416,6 +416,7 @@ function commitCompactionBody(
shadowedTokenCount,
summary,
rawOutput,
+ llmStreamCall,
provider,
model,
maxTokens,
@@ -425,6 +426,7 @@ function commitCompactionBody(
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
+ ...llmStreamCall === undefined ? {} : { llmStreamCall },
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,
diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts
index ba3dad5592..08aa810433 100644
--- a/packages/compact/compact-basic/src/summarizer.ts
+++ b/packages/compact/compact-basic/src/summarizer.ts
@@ -87,8 +87,16 @@ export interface SummarizationInput {
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
export interface SummaryResult {
summary: ContentBlock[]
- /** Complete provider output before the text-only summary projection. */
+ /**
+ * Complete provider output before the text-only summary projection; this
+ * alone does not identify the call path.
+ */
rawOutput?: ContentBlock[]
+ /**
+ * Present only when producing the summary consumed exactly one call through
+ * this context's `ctx.llm.stream()`.
+ */
+ llmStreamCall?: true
provider: string
model: string
maxTokens?: number
@@ -162,6 +170,7 @@ export async function summarizeWithLlm(
return {
summary,
rawOutput,
+ llmStreamCall: true,
provider: options.provider,
model: options.model,
maxTokens: config.maxTokens,
diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts
index a8efad741b..bd04335964 100644
--- a/packages/compact/compact-basic/tests/compact-basic.spec.ts
+++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts
@@ -868,6 +868,7 @@ describe('compaction region transaction', () => {
rawOutput: compact.rawOutput,
usage: compact.usage,
})
+ expect(summary?.data).not.toHaveProperty('llmStreamCall')
const head = session.deriveMessages()[0]!
expect(head.content[0]?.type).toBe('text')
expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('')
@@ -1187,6 +1188,7 @@ describe('default one-shot summarizer', () => {
{ type: 'text', text: 'public summary' },
{ type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' },
],
+ llmStreamCall: true,
provider: MODEL,
model: MODEL,
maxTokens: 321,
@@ -1300,6 +1302,7 @@ describe('default one-shot summarizer', () => {
await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL)
expect(session.events.findLast(event => event.type === 'compact/summary')?.data).toMatchObject({
summary: [{ type: 'text', text: 'routed summary' }],
+ llmStreamCall: true,
provider: 'routed-summary-provider',
model: 'routed-summary-model',
})
diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts
index 4a856a6848..4ed12d1e5f 100644
--- a/packages/compact/compact/src/types.ts
+++ b/packages/compact/compact/src/types.ts
@@ -28,8 +28,16 @@ declare module '@deepseek-ai/dsh-session' {
*/
'compact/summary': {
summary: ContentBlock[]
- /** Complete provider output before the backend's safe summary projection. */
+ /**
+ * Complete provider output before the backend's safe summary projection;
+ * this alone does not identify the call path.
+ */
rawOutput?: ContentBlock[]
+ /**
+ * Present only when producing the summary consumed exactly one call
+ * through this context's `ctx.llm.stream()`.
+ */
+ llmStreamCall?: true
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
shadowedTokenCount: number
diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml
index 3f3e349a84..e120b5007b 100644
--- a/packages/support/llm-replay/README.i18n.yaml
+++ b/packages/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/support/llm-replay/README.md
-README.md: 46d391970f320708914d11f0868cbbc5361ae196
-README.zh.md: a67b078a1396968dc3ddecb0e616a832c4faaf3a
+README.md: a6c087778b8e64124590fb6be7a652b58e1b6343
+README.zh.md: 241edb9c9b2400519155ccd7161eb7f34b9f5bbe
diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md
index 46d391970f..a6c087778b 100644
--- a/packages/support/llm-replay/README.md
+++ b/packages/support/llm-replay/README.md
@@ -8,7 +8,7 @@ Its consumers are the ACP and headless `stream-json` snapshot suites plus the We
## How the fixture works
-The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each agent-loop `stream()` call's chunk sequence. A successful compaction summarizer is logged differently: when `compact/summary` carries its complete `rawOutput`, replay reconstructs a canonical successful stream at that event's position using one `block-start`/`block-end` pair per block, the recorded usage when present, and a terminal `stop`. Exact provider delta partitioning is not part of the durable compaction result. A summary without `rawOutput` does not imply an LLM call because template and remote summarizers may produce it without the local adapter.
+The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each agent-loop `stream()` call's chunk sequence. A successful compaction summarizer is logged differently: when `compact/summary` carries `llmStreamCall: true` and its complete `rawOutput`, replay reconstructs a canonical successful stream at that event's position using one `block-start`/`block-end` pair per block, the recorded usage when present, and a terminal `stop`. Exact provider delta partitioning is not part of the durable compaction result. `rawOutput` without the marker does not imply a local LLM call because template and remote summarizers may retain complete output without using this context's adapter.
Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` and `compact/summary` events plus the line-0 session header.
@@ -59,7 +59,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars.
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing).
-- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn ordinary loop chunks and complete compaction outputs in a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived assistant group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
+- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn ordinary loop chunks and explicitly marked local compaction outputs in a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived assistant group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
- Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`.
## Plugin export shape
@@ -77,4 +77,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`).
-- **Only ordinary loop chunks and completed compaction outputs are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs.
+- **Only ordinary loop chunks and marked local compaction outputs are derivable** — a pure pre-chunk throw, a cancel/hang, or an unmarked external summarizer call needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs.
diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md
index a67b078a13..241edb9c9b 100644
--- a/packages/support/llm-replay/README.zh.md
+++ b/packages/support/llm-replay/README.zh.md
@@ -8,7 +8,7 @@
## fixture 的工作方式
-fixture 就是持久化的会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 agent-loop `stream()` 调用的分片序列。压缩(compaction)摘要器成功时,日志记录方式有所不同:当 `compact/summary` 携带完整的 `rawOutput` 时,回放会在该事件的位置重建一条规范成功流,其中每个块各使用一对 `block-start`/`block-end`,带上已记录的 usage(如有),并以 `stop` 终止。提供方增量的精确切分不属于持久压缩结果。不带 `rawOutput` 的摘要并不意味着发生了 LLM 调用,因为模板摘要器和远程摘要器可能不经本地适配器生成该摘要。
+fixture 就是持久化的会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 agent-loop `stream()` 调用的分片序列。压缩(compaction)摘要器成功时,日志记录方式有所不同:当 `compact/summary` 携带 `llmStreamCall: true` 和完整的 `rawOutput` 时,回放会在该事件的位置重建一条规范成功流,其中每个块各使用一对 `block-start`/`block-end`,带上已记录的 usage(如有),并以 `stop` 终止。提供方增量的精确切分不属于持久压缩结果。不带该标记的 `rawOutput` 并不意味着发生了本地 LLM 调用,因为模板摘要器和远程摘要器即使未使用此上下文的适配器,也可能保留完整输出。
因此,录制就是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件本身不录制。fixture 的 `request/header` 内容可能被标记化为 `{{system}}`/`{{tools}}`(harness 会在一个场景中固定该内容,并清除其余场景中的内容);回放不受影响,因为派生过程只读取 `assistant/chunk` 和 `compact/summary` 事件以及第 0 行的会话 header。
@@ -59,7 +59,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as
- `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。
- `loadSessionScripts(config)`:解析场景中有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。
- `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。
-- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志中的普通 loop 分片和完整压缩输出转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生的 assistant 分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。
+- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志中的普通 loop 分片和显式标记的本地压缩输出转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生的 assistant 分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。
- 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。
## 插件导出形态
@@ -77,4 +77,4 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as
## 已知限制与暂缓事项
- **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut 会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。
-- **只有普通 loop 分片和已完成的压缩输出才能派生**:在产生分片前直接抛出异常或取消/挂起的场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。
+- **只有普通 loop 分片和带标记的本地压缩输出才能派生**:在产生分片前直接抛出异常、取消/挂起,或未标记的外部摘要器调用场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。
diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts
index 8733a4296c..abed4258af 100644
--- a/packages/support/llm-replay/src/index.ts
+++ b/packages/support/llm-replay/src/index.ts
@@ -177,8 +177,9 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe
* Reconstruct the per-`stream()` replay script from a recorded session log.
*
* Splits `assistant/chunk` events at every `finish`, using turn and step changes
- * to detect an unterminated prior call. A complete `compact/summary.rawOutput`
- * becomes a canonical successful stream at the summary's log position. A
+ * to detect an unterminated prior call. A `compact/summary` explicitly marked
+ * as one local LLM-stream call becomes a canonical successful stream from its
+ * complete `rawOutput` at the summary's log position. A
* missing assistant terminator means the live stream threw, so derivation
* rejects and the scenario must provide an explicit override. Multiple calls
* may share one turn and step when the loop retries.
@@ -204,7 +205,10 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
close(currentKey, current)
currentKey = undefined
current = []
- if (event.data.rawOutput !== undefined) {
+ if (event.data.llmStreamCall === true) {
+ if (event.data.rawOutput === undefined) {
+ throw new Error('llm-replay: compact/summary marks an LLM stream call without rawOutput')
+ }
const chunks: StreamChunk[] = []
for (const [index, block] of event.data.rawOutput.entries()) {
chunks.push({ type: 'block-start', index, blockType: block.type })
diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts
index 9483a4c4f6..7baa6249b9 100644
--- a/packages/support/llm-replay/tests/llm-replay.spec.ts
+++ b/packages/support/llm-replay/tests/llm-replay.spec.ts
@@ -202,6 +202,7 @@ describe('deriveReplayScript', () => {
data: {
summary: rawOutput,
rawOutput,
+ llmStreamCall: true,
shadowedRange: { start: 1, end: 1 },
shadowedSeqs: [1],
shadowedTokenCount: 20,
@@ -238,6 +239,45 @@ describe('deriveReplayScript', () => {
expect(deriveReplayScript([event])).toEqual([])
})
+ it('does not infer a local LLM call from external compact output', () => {
+ const block = { type: 'text' as const, text: 'remote summary' }
+ const event: SessionEvent<'compact/summary'> = {
+ type: 'compact/summary',
+ seq: 1,
+ time: 0,
+ data: {
+ summary: [block],
+ rawOutput: [block],
+ shadowedRange: { start: 1, end: 1 },
+ shadowedSeqs: [1],
+ shadowedTokenCount: 20,
+ provider: 'remote',
+ model: 'remote',
+ },
+ }
+
+ expect(deriveReplayScript([event])).toEqual([])
+ })
+
+ it('rejects a marked compact LLM call without its complete output', () => {
+ const event: SessionEvent<'compact/summary'> = {
+ type: 'compact/summary',
+ seq: 1,
+ time: 0,
+ data: {
+ summary: [{ type: 'text', text: 'incomplete provenance' }],
+ llmStreamCall: true,
+ shadowedRange: { start: 1, end: 1 },
+ shadowedSeqs: [1],
+ shadowedTokenCount: 20,
+ provider: 'mock',
+ model: 'mock',
+ },
+ }
+
+ expect(() => deriveReplayScript([event])).toThrow(/LLM stream call without rawOutput/)
+ })
+
it('derives a compact/summary stream when usage is unavailable', () => {
const block = { type: 'text' as const, text: 'summary without usage' }
const event: SessionEvent<'compact/summary'> = {
@@ -247,6 +287,7 @@ describe('deriveReplayScript', () => {
data: {
summary: [block],
rawOutput: [block],
+ llmStreamCall: true,
shadowedRange: { start: 1, end: 1 },
shadowedSeqs: [1],
shadowedTokenCount: 20,
@@ -289,6 +330,27 @@ describe('deriveReplayScript', () => {
]
expect(() => deriveReplayScript(events)).toThrow(/model call 1\/1 ended without a finish chunk/)
})
+
+ it('rejects an unfinished call at a compact summary boundary', () => {
+ const events: SessionEvent[] = [
+ chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }),
+ {
+ type: 'compact/summary',
+ seq: 2,
+ time: 0,
+ data: {
+ summary: [{ type: 'text', text: 'external checkpoint' }],
+ shadowedRange: { start: 1, end: 1 },
+ shadowedSeqs: [1],
+ shadowedTokenCount: 20,
+ provider: 'external',
+ model: 'external',
+ },
+ },
+ ]
+
+ expect(() => deriveReplayScript(events)).toThrow(/model call 1\/1 ended without a finish chunk/)
+ })
})
describe('loadReplayScript', () => {
From 37e4e1585b9e2cc123b9f47d7b996318d022da7f Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sat, 8 Aug 2026 14:45:23 +0800
Subject: [PATCH 2/4] fix(compact): enforce replay provenance
---
.../core-data-structures/compaction.i18n.yaml | 4 +--
docs/core-data-structures/compaction.md | 2 +-
docs/core-data-structures/compaction.zh.md | 2 +-
docs/persistence-catalog.md | 29 ++++++++-------
.../compact/compact-basic/README.i18n.yaml | 4 +--
packages/compact/compact-basic/README.md | 2 +-
packages/compact/compact-basic/README.zh.md | 2 +-
packages/compact/compact-basic/src/region.ts | 10 +++---
.../compact/compact-basic/src/summarizer.ts | 27 +++++++-------
.../compact-basic/tests/compact-basic.spec.ts | 13 +++++--
packages/compact/compact/src/types.ts | 25 +++++++------
packages/support/llm-replay/src/index.ts | 27 +++++++++-----
.../llm-replay/tests/llm-replay.spec.ts | 36 ++++++++++---------
13 files changed, 107 insertions(+), 76 deletions(-)
diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml
index c5cd2a0b38..2ad9ea8e8a 100644
--- a/docs/core-data-structures/compaction.i18n.yaml
+++ b/docs/core-data-structures/compaction.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md
-compaction.md: b17dfc03c62c0453c45ca2810ef90bdce9ae9ac7
-compaction.zh.md: b904cdd3ad5bf63e968fc22fccb32c5220ab8763
+compaction.md: f1df5b83bd43136af60988dabd9dc68fe32467a2
+compaction.zh.md: 52540250d466f81f51cf7c681bb6f1436e29a12d
diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md
index b17dfc03c6..f1df5b83bd 100644
--- a/docs/core-data-structures/compaction.md
+++ b/docs/core-data-structures/compaction.md
@@ -13,7 +13,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de
| Event | Payload | Role |
|---|---|---|
| `compact/start` | `{ turn }` | acquires the log-recorded lock; a number identifies the open automatic turn, while `null` identifies a standalone manual attempt |
-| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, an `llmStreamCall: true` marker when producing the result consumed exactly one call through this context's `ctx.llm.stream()`, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note); `rawOutput` alone does not identify the call path |
+| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, an `llmStreamCall: true` marker when producing the result consumed exactly one call through this context's `ctx.llm.stream()` (which requires complete `rawOutput`), the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note); unmarked `rawOutput` does not identify the call path |
| `compact/end` | `{ turn, error? }` | releases the lock with the same numeric-or-null owner (`error` records an unsuccessful attempt) |
The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished.
diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md
index b904cdd3ad..52540250d4 100644
--- a/docs/core-data-structures/compaction.zh.md
+++ b/docs/core-data-structures/compaction.zh.md
@@ -13,7 +13,7 @@
| 事件 | 载荷 | 作用 |
|---|---|---|
| `compact/start` | `{ turn }` | 获取日志记录的锁;数字标识打开的自动轮次,`null` 标识独立手动尝试 |
-| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、生成结果时恰好通过此上下文的 `ctx.llm.stream()` 发起一次调用所带的 `llmStreamCall: true` 标记、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note);单有 `rawOutput` 并不能判定调用路径 |
+| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、生成结果时恰好通过此上下文的 `ctx.llm.stream()` 发起一次调用所带的 `llmStreamCall: true` 标记(此时必须提供完整的 `rawOutput`)、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note);未带标记的 `rawOutput` 并不能判定调用路径 |
| `compact/end` | `{ turn, error? }` | 使用相同的数字或 `null` 归属值释放锁(`error` 记录失败尝试) |
锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 808a03b6f4..7538892445 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -237,7 +237,7 @@ Source: [`packages/ui/commands/src/index.ts:139`](../packages/ui/commands/src/in
'compact/end': { turn: number | null; error?: string }
```
-Source: [`packages/compact/compact/src/types.ts:62`](../packages/compact/compact/src/types.ts)
+Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts)
#### `compact/prune` — log-only
@@ -261,7 +261,7 @@ Source: [`packages/compact/compact/src/types.ts:62`](../packages/compact/compact
}
```
-Source: [`packages/compact/compact/src/types.ts:72`](../packages/compact/compact/src/types.ts)
+Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts)
#### `compact/start` — log-only
@@ -290,16 +290,6 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact
*/
'compact/summary': {
summary: ContentBlock[]
- /**
- * Complete provider output before the backend's safe summary projection;
- * this alone does not identify the call path.
- */
- rawOutput?: ContentBlock[]
- /**
- * Present only when producing the summary consumed exactly one call
- * through this context's `ctx.llm.stream()`.
- */
- llmStreamCall?: true
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
shadowedTokenCount: number
@@ -316,7 +306,20 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact
maxTokens?: number
/** Provider-reported token usage for the summarization request, when emitted. */
usage?: TokenUsage
-}
+} & (
+ | {
+ /** Complete provider output before the backend's safe summary projection. */
+ rawOutput: ContentBlock[]
+ /** Identifies exactly one call through this context's `ctx.llm.stream()`. */
+ llmStreamCall: true
+ }
+ | {
+ /** Optional complete output from an unmarked template, remote, or other summarizer. */
+ rawOutput?: ContentBlock[]
+ /** An unmarked summary does not identify a call through this context's LLM seam. */
+ llmStreamCall?: never
+ }
+)
```
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml
index 062021a4c1..61b6e4e6fb 100644
--- a/packages/compact/compact-basic/README.i18n.yaml
+++ b/packages/compact/compact-basic/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/compact/compact-basic/README.md
-README.md: d1c1dfb509ae0750e1237532a829a35de5084c5e
-README.zh.md: a78887daad04d534ffed9cbd0357b76bdd908f5e
+README.md: 4241899788998a744801bb0406a15ecd70af6401
+README.zh.md: c1df4afaa1837a3b689da80cc1b49df43f60e513
diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md
index d1c1dfb509..4241899788 100644
--- a/packages/compact/compact-basic/README.md
+++ b/packages/compact/compact-basic/README.md
@@ -21,7 +21,7 @@ This backend owns the compaction policy:
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after cleanup and durability.
-The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`); `llmStreamCall: true` means producing that result consumed exactly one call through this context's `ctx.llm.stream()`, while `rawOutput` alone does not identify the call path. The transaction preserves those fields on `compact/summary`.
+The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`); `llmStreamCall: true` means producing that result consumed exactly one call through this context's `ctx.llm.stream()` and requires complete `rawOutput`, while unmarked `rawOutput` does not identify the call path. The transaction preserves those fields on `compact/summary`.
## Config (`BasicCompactConfig`)
diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md
index a78887daad..c1df4afaa1 100644
--- a/packages/compact/compact-basic/README.zh.md
+++ b/packages/compact/compact-basic/README.zh.md
@@ -21,7 +21,7 @@
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
- **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。
-受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`);`llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,而单有 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。
+受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`);`llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,且必须提供完整的 `rawOutput`;未带标记的 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。
## 配置(`BasicCompactConfig`)
diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts
index 67516a830d..352603579b 100644
--- a/packages/compact/compact-basic/src/region.ts
+++ b/packages/compact/compact-basic/src/region.ts
@@ -43,7 +43,7 @@ interface PreparedCompaction extends SurfaceSelection {
readonly input: SummarizationInput
}
-interface SummarizedCompaction extends PreparedCompaction, SummaryResult {
+type SummarizedCompaction = PreparedCompaction & SummaryResult & {
readonly checkpointMessage: UserMessage
}
@@ -415,18 +415,18 @@ function commitCompactionBody(
shadowedSeqs,
shadowedTokenCount,
summary,
- rawOutput,
- llmStreamCall,
provider,
model,
maxTokens,
usage,
checkpointMessage,
} = summarized
+ const callProvenance = summarized.llmStreamCall === true
+ ? { rawOutput: summarized.rawOutput, llmStreamCall: true as const }
+ : summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput }
const summaryEvent = session.append('compact/summary', {
summary,
- ...rawOutput === undefined ? {} : { rawOutput },
- ...llmStreamCall === undefined ? {} : { llmStreamCall },
+ ...callProvenance,
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,
diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts
index 08aa810433..8113dcd439 100644
--- a/packages/compact/compact-basic/src/summarizer.ts
+++ b/packages/compact/compact-basic/src/summarizer.ts
@@ -85,24 +85,27 @@ export interface SummarizationInput {
}
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
-export interface SummaryResult {
+export type SummaryResult = {
summary: ContentBlock[]
- /**
- * Complete provider output before the text-only summary projection; this
- * alone does not identify the call path.
- */
- rawOutput?: ContentBlock[]
- /**
- * Present only when producing the summary consumed exactly one call through
- * this context's `ctx.llm.stream()`.
- */
- llmStreamCall?: true
provider: string
model: string
maxTokens?: number
/** Provider-reported usage for this summarization request. */
usage?: TokenUsage
-}
+} & (
+ | {
+ /** Complete provider output before the text-only summary projection. */
+ rawOutput: ContentBlock[]
+ /** Identifies exactly one call through this context's `ctx.llm.stream()`. */
+ llmStreamCall: true
+ }
+ | {
+ /** Optional complete output from an unmarked template, remote, or other summarizer. */
+ rawOutput?: ContentBlock[]
+ /** An unmarked result does not identify a call through this context's LLM seam. */
+ llmStreamCall?: never
+ }
+)
/**
* Run the default cache-reusing `ctx.llm.stream()` summarization call: replay
diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts
index bd04335964..0e74a77e04 100644
--- a/packages/compact/compact-basic/tests/compact-basic.spec.ts
+++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts
@@ -1,9 +1,9 @@
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
-import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
+import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import {
resolveCompactSpec,
@@ -1166,6 +1166,15 @@ async function summarizerHarness(
}
describe('default one-shot summarizer', () => {
+ it('requires complete raw output when a subclass marks one local LLM stream call', () => {
+ expectTypeOf<{
+ summary: ContentBlock[]
+ llmStreamCall: true
+ provider: string
+ model: string
+ }>().not.toExtend()
+ })
+
it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => {
const { adapter, compact } = await summarizerHarness([
{ type: 'reasoning', text: 'private' },
diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts
index 4ed12d1e5f..c235507c6a 100644
--- a/packages/compact/compact/src/types.ts
+++ b/packages/compact/compact/src/types.ts
@@ -28,16 +28,6 @@ declare module '@deepseek-ai/dsh-session' {
*/
'compact/summary': {
summary: ContentBlock[]
- /**
- * Complete provider output before the backend's safe summary projection;
- * this alone does not identify the call path.
- */
- rawOutput?: ContentBlock[]
- /**
- * Present only when producing the summary consumed exactly one call
- * through this context's `ctx.llm.stream()`.
- */
- llmStreamCall?: true
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
shadowedTokenCount: number
@@ -54,7 +44,20 @@ declare module '@deepseek-ai/dsh-session' {
maxTokens?: number
/** Provider-reported token usage for the summarization request, when emitted. */
usage?: TokenUsage
- }
+ } & (
+ | {
+ /** Complete provider output before the backend's safe summary projection. */
+ rawOutput: ContentBlock[]
+ /** Identifies exactly one call through this context's `ctx.llm.stream()`. */
+ llmStreamCall: true
+ }
+ | {
+ /** Optional complete output from an unmarked template, remote, or other summarizer. */
+ rawOutput?: ContentBlock[]
+ /** An unmarked summary does not identify a call through this context's LLM seam. */
+ llmStreamCall?: never
+ }
+ )
/**
* Marks the end of a compaction — log-only, releases the lock. Its owner
* matches `compact/start`; `error` records an unsuccessful attempt.
diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts
index abed4258af..9af72bbbec 100644
--- a/packages/support/llm-replay/src/index.ts
+++ b/packages/support/llm-replay/src/index.ts
@@ -1,7 +1,7 @@
/**
* Keyless snapshot-test LLM replay. It derives one model-call script per
- * recorded session from `assistant/chunk` events and durable compaction
- * summaries, then binds fresh live sessions to parent/child scripts by
+ * recorded session from `assistant/chunk` events and explicitly marked local
+ * compaction calls, then binds fresh live sessions to parent/child scripts by
* first-call order. Throw and hang cases require an explicit override because
* a session log cannot reconstruct them alone.
* @module @deepseek-ai/dsh-llm-replay
@@ -14,6 +14,7 @@ import type {} from '@deepseek-ai/dsh-compact'
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type {
+ ContentBlock,
GenerateOptions,
LlmModelInfo,
LlmProviderInfo,
@@ -21,14 +22,15 @@ import type {
ResolvedRetryPolicy,
RetryPolicyConfig,
StreamChunk,
+ TokenUsage,
} from '@deepseek-ai/dsh-llm'
import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
/**
* One recorded model call. `throw` may replay prefix chunks before failing;
- * `hang` models cancellation. Chunk entries derive from ordinary model streams
- * and complete compaction outputs in JSONL; the other variants come from an
- * override sidecar.
+ * `hang` models cancellation. Derived chunk entries come from ordinary model
+ * streams and complete outputs of explicitly marked local compaction calls;
+ * an override sidecar can supply any variant.
*/
export type ReplayEntry =
| { kind: 'chunks'; chunks: StreamChunk[] }
@@ -205,16 +207,23 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
close(currentKey, current)
currentKey = undefined
current = []
- if (event.data.llmStreamCall === true) {
- if (event.data.rawOutput === undefined) {
+ // JSONL decoding crosses an untyped durable boundary, so retain its wider
+ // shape even though current in-process producers enforce this correlation.
+ const persisted: {
+ readonly llmStreamCall?: true
+ readonly rawOutput?: ContentBlock[]
+ readonly usage?: TokenUsage
+ } = event.data
+ if (persisted.llmStreamCall === true) {
+ if (persisted.rawOutput === undefined) {
throw new Error('llm-replay: compact/summary marks an LLM stream call without rawOutput')
}
const chunks: StreamChunk[] = []
- for (const [index, block] of event.data.rawOutput.entries()) {
+ for (const [index, block] of persisted.rawOutput.entries()) {
chunks.push({ type: 'block-start', index, blockType: block.type })
chunks.push({ type: 'block-end', index, block })
}
- if (event.data.usage !== undefined) chunks.push({ type: 'usage', usage: event.data.usage })
+ if (persisted.usage !== undefined) chunks.push({ type: 'usage', usage: persisted.usage })
chunks.push({ type: 'finish', reason: { kind: 'stop' } })
script.push({ kind: 'chunks', chunks })
}
diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts
index 7baa6249b9..4be9e01c9d 100644
--- a/packages/support/llm-replay/tests/llm-replay.spec.ts
+++ b/packages/support/llm-replay/tests/llm-replay.spec.ts
@@ -259,23 +259,27 @@ describe('deriveReplayScript', () => {
expect(deriveReplayScript([event])).toEqual([])
})
- it('rejects a marked compact LLM call without its complete output', () => {
- const event: SessionEvent<'compact/summary'> = {
- type: 'compact/summary',
- seq: 1,
- time: 0,
- data: {
- summary: [{ type: 'text', text: 'incomplete provenance' }],
- llmStreamCall: true,
- shadowedRange: { start: 1, end: 1 },
- shadowedSeqs: [1],
- shadowedTokenCount: 20,
- provider: 'mock',
- model: 'mock',
- },
- }
+ it('rejects a persisted marked compact LLM call without its complete output', () => {
+ const [event] = parseSessionLog([
+ JSON.stringify({ type: 'session', version: 0, id: 'invalid-compact', createdAt: 0 }),
+ JSON.stringify({
+ type: 'compact/summary',
+ seq: 1,
+ time: 0,
+ data: {
+ summary: [{ type: 'text', text: 'incomplete provenance' }],
+ llmStreamCall: true,
+ shadowedRange: { start: 1, end: 1 },
+ shadowedSeqs: [1],
+ shadowedTokenCount: 20,
+ provider: 'mock',
+ model: 'mock',
+ },
+ }),
+ ].join('\n'))
- expect(() => deriveReplayScript([event])).toThrow(/LLM stream call without rawOutput/)
+ expect(() => deriveReplayScript(event === undefined ? [] : [event]))
+ .toThrow(/LLM stream call without rawOutput/)
})
it('derives a compact/summary stream when usage is unavailable', () => {
From 49dff10abd50452666f994714eec298974738aa6 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sat, 8 Aug 2026 14:45:37 +0800
Subject: [PATCH 3/4] fix(workspace-context): commit projections after tool
steps
---
.../2026-06-24-workspace-context.i18n.yaml | 4 +-
.../feature/2026-06-24-workspace-context.md | 10 +-
.../2026-06-24-workspace-context.zh.md | 10 +-
docs/event-producer-consumer.md | 2 +-
.../workspace-context/README.i18n.yaml | 4 +-
packages/context/workspace-context/README.md | 6 +-
.../context/workspace-context/README.zh.md | 6 +-
.../context/workspace-context/src/index.ts | 68 ++++++++++-
.../tests/workspace-context.spec.ts | 106 +++++++++++++++++-
9 files changed, 188 insertions(+), 28 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml
index 170631b6a4..0369670aea 100644
--- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md
-2026-06-24-workspace-context.md: 3c21b7599e6bb2dd759e4a040c1b999f1b0516cd
-2026-06-24-workspace-context.zh.md: e913ecb22b4692e1ee7b96d4f5effe2606775cf8
+2026-06-24-workspace-context.md: 53a580aee50752b7c6daeff5caa42ba6409c8885
+2026-06-24-workspace-context.zh.md: dc1d6b5dc2c9f576e7298c891a3a1145f123a8db
diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md
index 3c21b7599e..53a580aee5 100644
--- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md
+++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md
@@ -14,7 +14,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain
## Decision
-The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/pre-step`, `tools/post-execute`, and the optional `ctx.fs` capability.
+The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/pre-step`, immutable `tools/result` outcomes, `session/event` boundaries, and the optional `ctx.fs` capability.
The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The step signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope.
@@ -38,7 +38,7 @@ The baseline is a user-role `` with `Instructions from: `
### Dynamic Discovery And Refresh
-After a successful first-party `read`, `write`, or `edit` call, the `tools/post-execute` listener reconciles the touched descendant chain and every scope already known to the session. A newly reached scope is returned through `additionalContexts` for the next request using an `Additional instructions from: ` system-reminder. Under Code Mode, `run_code` defers sub-dispatch contexts onto its outer result, so the same update is appended only after the parent result rather than being injected mid-call.
+After a successful first-party `read`, `write`, or `edit` call, the immutable `tools/result` observer reconciles the touched descendant chain and every scope already known to the session, then queues an `Additional instructions from: ` system-reminder in the agent inbox for the next request. Under Code Mode, successful sub-dispatch touches bubble through opaque parent execution tokens until the top-level result settles. A touch produced inside an agent-loop step does not begin its asynchronous projection until the durable `step/end`; a direct tool execution outside an open step projects immediately. The two boundaries keep result and step adjacency deterministic without making the tool pipeline await filesystem discovery.
A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply.
@@ -50,7 +50,7 @@ Shell commands are not discovery triggers. Local bash calls start fresh shells,
Every workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. Complete baselines additionally carry `baseline: true` and `baselineIdentity`. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
-At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy.
+At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. Successful nested touches aggregate under their parent execution token, including when a later composite result is blocked; the top-level result transfers them either to the open session step or directly to a per-agent projection queue. A `step/end` releases its staged touches only after that boundary is durable, and the next `agent/pre-step` waits for the serialized projections. Each projection composes against visible history plus the current inbox and replaces the single pending workspace context instead of accumulating intermediate renderings.
An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata: a compatible visible baseline supplies comparison state rather than causing another complete baseline to be appended. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch.
@@ -62,7 +62,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc
`maxBytes` is required and applies separately to a rendered baseline or one dynamic reconciliation batch; there is no implicit or unbounded render budget. Non-positive and non-finite values disable loading. When content exceeds the budget, broader files are omitted before the most-specific file is truncated. A visible `Workspace instruction budget ...` notice names omitted and truncated paths and byte counts, and output never exceeds the configured bytes.
-`maxSourceBytes` is a positive per-file cap with a 1 MiB default. The loader checks reported size before reading and still consumes content through `streamText()` with a running UTF-8 byte count, so missing/stale metadata cannot force an unbounded allocation. An oversized winning candidate is unavailable rather than a reason to fall through to another same-directory name. The plugin deliberately keeps no process-wide cache and never retains instruction prose. It keeps only `{ path, version, digest }` per effective scope in a `WeakMap>`: a matching provider `FsVersion` plus matching effective prompt state skips the read, while a changed version triggers a bounded read and SHA-1 confirmation. SHA-1 remains the cross-provider content identity persisted in visible structured metadata; provider versions are only an in-memory invalidation fast path. Cache transitions for model-visible changes commit only when the corresponding context survives the complete tool-result policy chain, and are invalidated if that accepted context is later dropped with its aborted step before reaching the log.
+`maxSourceBytes` is a positive per-file cap with a 1 MiB default. The loader checks reported size before reading and still consumes content through `streamText()` with a running UTF-8 byte count, so missing/stale metadata cannot force an unbounded allocation. An oversized winning candidate is unavailable rather than a reason to fall through to another same-directory name. The plugin deliberately keeps no process-wide cache and never retains instruction prose. It keeps only `{ path, version, digest }` per effective scope in a `WeakMap>`: a matching provider `FsVersion` plus matching effective prompt state skips the read, while a changed version triggers a bounded read and SHA-1 confirmation. SHA-1 remains the cross-provider content identity persisted in visible structured metadata; provider versions are only an in-memory invalidation fast path. Dynamic cache transitions occur while a serialized projection composes the one desired inbox context, and the next pre-step waits for that projection before deciding what enters the request.
## Alternatives considered
@@ -78,7 +78,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc
## Consequences
-Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through injected messages and post-tool `additionalContexts` arrays without flattening entries.
+Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries.
Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk).
diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md
index e913ecb22b..dc1d6b5dc2 100644
--- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md
+++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md
@@ -14,7 +14,7 @@ Status: implemented
## 决策
-该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/pre-step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。
+该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/pre-step`、不可变的 `tools/result` 结果、`session/event` 边界和可选的 `ctx.fs` 功能。
插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。步骤信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。
@@ -38,7 +38,7 @@ Status: implemented
### 动态发现与刷新
-第一方 `read`、`write` 或 `edit` 调用成功后,`tools/post-execute` 监听器会协调被触及的后代路径链,以及该会话已经知道的每个作用域。新到达的作用域通过 `additionalContexts` 返回,并在下一次请求中使用 `Additional instructions from: ` system-reminder。在 Code Mode 下,`run_code` 会把子分发上下文延后至其外层结果,因此同一更新只会在父结果之后追加,而不会在调用中途注入。
+第一方 `read`、`write` 或 `edit` 调用成功后,不可变的 `tools/result` 观察器会协调被触及的后代路径链,以及该会话已经知道的每个作用域,然后在 agent inbox 中排入一条 `Additional instructions from: ` system-reminder,供下一次请求使用。在 Code Mode 下,成功的子分派 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定。在 agent loop 步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影;打开的步骤之外直接执行工具时,则立即投影。这两个边界在不让工具流水线等待文件系统发现的前提下,保证结果/步骤的相邻关系具有确定性。
内容编辑会追加 `Updated instructions from: `,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: `,并说明先前加载的指令不再适用。
@@ -50,7 +50,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,
每个工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。完整基线还会额外携带 `baseline: true` 和 `baselineIdentity`。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。
-协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。
+协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。即使后续复合结果被拦截,成功的嵌套 touch 也会聚合到父级执行 token 下;顶层结果会将它们交给打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界持久化后释放其暂存的 touch;下一次 `agent/pre-step` 会等待串行投影完成。每次投影都会根据可见历史和当前 inbox 进行组合,并替换唯一一条待处理工作区上下文,而不会累积中间渲染结果。
路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作:兼容的可见基线会提供比较状态,而不会导致再次追加完整基线。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。
@@ -62,7 +62,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,
`maxBytes` 是必填项,分别作用于渲染后的基线或单个动态协调批次;系统不存在隐式或无界的渲染预算。非正数或非有限值会禁用加载。内容超过预算时,系统会先省略较宽泛的文件,再截断最具体的文件。可见的 `Workspace instruction budget ...` 提示会指出被省略和截断的路径与字节数,并且输出绝不超过配置字节数。
-`maxSourceBytes` 是正数的逐文件上限,默认为 1 MiB。loader 会在读取前检查报告的大小,同时仍通过 `streamText()` 消费内容并持续统计 UTF-8 字节数,因此缺失/陈旧的元数据无法迫使其进行无界分配。过大的胜出候选项会被视为不可用,而不是改为同目录中的下一个名称。插件刻意不保留进程级缓存,也绝不保留指令正文。它只为每个有效作用域保存 `{ path, version, digest }`,并将这些状态放在 `WeakMap>` 中:提供方 `FsVersion` 与有效提示词状态同时匹配时跳过读取;版本变化则触发有界读取和 SHA-1 确认。SHA-1 仍是持久化在可见结构化元数据中的跨提供方内容标识;提供方版本只作为内存中的失效快速路径。模型可见变更的缓存转换只有在相应上下文通过完整的工具结果策略链后才会提交;如果该已接受上下文随后与中止步骤一起被丢弃、未能进入日志,缓存转换就会失效。
+`maxSourceBytes` 是正数的逐文件上限,默认为 1 MiB。loader 会在读取前检查报告的大小,同时仍通过 `streamText()` 消费内容并持续统计 UTF-8 字节数,因此缺失/陈旧的元数据无法迫使其进行无界分配。过大的胜出候选项会被视为不可用,而不是改为同目录中的下一个名称。插件刻意不保留进程级缓存,也绝不保留指令正文。它只为每个有效作用域保存 `{ path, version, digest }`,并将这些状态放在 `WeakMap>` 中:提供方 `FsVersion` 与有效提示词状态同时匹配时跳过读取;版本变化则触发有界读取和 SHA-1 确认。SHA-1 仍是持久化在可见结构化元数据中的跨提供方内容标识;提供方版本只作为内存中的失效快速路径。动态缓存转换发生在串行投影组合唯一一条目标 inbox 上下文时;下一次 pre-step 会等待该投影完成,再决定哪些内容进入请求。
## 考虑过的替代方案
@@ -78,7 +78,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,
## 后果
-工作区指引按会话隔离,并由 demo 入口、Web Host 与每一种工具展示模式共享。初始、嵌套与变更指令都保持持久且可回放。通用的会话/agent 上下文契约通过注入消息与工具执行后的 `additionalContexts` 数组携带带类型的来源数据,而不会把条目展平。
+工作区指引按会话隔离,并由 demo 入口、Web Host 与每一种工具展示模式共享。初始、嵌套与变更指令都保持持久且可回放。通用的会话/agent 上下文契约通过先在 inbox 中暂存、再持久进入的 user 消息携带带类型的来源数据,而不会把条目展平。
仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该攻击面扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 13e96e9dd8..c46ee92578 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -32,7 +32,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`loader-smoke`](../packages/support/loader-smoke), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`loader-smoke`](../packages/support/loader-smoke), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml
index 753836e528..08b1d7a675 100644
--- a/packages/context/workspace-context/README.i18n.yaml
+++ b/packages/context/workspace-context/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/context/workspace-context/README.md
-README.md: 82aee27a8fbd6a1ab0f0860226b081e28ba72e6e
-README.zh.md: 9d983f95f4e018cb8fe983d9862cf5f31f83c5f2
+README.md: 7ab21bbf8c72f8424bc8d4fdad9153c7ed8bb7e9
+README.zh.md: 7ad68759ab811cdc5e848fd686c7fad612c9b6d9
diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md
index 82aee27a8f..7ab21bbf8c 100644
--- a/packages/context/workspace-context/README.md
+++ b/packages/context/workspace-context/README.md
@@ -8,7 +8,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates. A resumed session retains one compatible visible baseline and appends only current-file transitions; a changed discovery, precedence, project-root, or budget identity instead folds one explicitly superseding complete baseline into the entering batch.
-The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
+The plugin also observes immutable `tools/result` outcomes for successful first-party `read`, `write`, and `edit` calls. Each accepted touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file queues an addition in the agent inbox; a changed file queues a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate queues a removal notice. Native calls and Code Mode sub-dispatches share this path: nested touches bubble through opaque parent execution tokens until the top-level result settles, and touches produced inside an agent-loop step do not begin their asynchronous projection until the durable `step/end`. Direct tool executions outside an open step project immediately. This preserves tool-call/result/step adjacency without depending on filesystem timing. Discovery follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It resolves each candidate and stats the result, so a final-component symlink is followed to its target: a link to a regular file loads that target's content, while a missing path or a non-file target (including a link to a directory) is a confirmed absence. A resolve or stat exception instead marks that candidate's scope temporarily unavailable. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
@@ -48,7 +48,7 @@ The plugin owns the complete `` framing, and every injected `us
## State And Refresh
-Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step folds newly composed context into its final batch immediately after the claimed messages and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
+Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step waits for every queued projection, folds newly composed context into its final batch immediately after the claimed messages, and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. Nested results aggregate successful file touches under their parent execution token, including when a later composite result is blocked; the top-level result transfers those touches either to the currently open session step or directly to the per-agent projection queue. A `step/end` releases its staged touches only after that boundary is in durable history, and serialized projections reconcile against visible session events plus the current inbox before replacing the single pending workspace context.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache.
@@ -129,7 +129,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
#### Token effect
-Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result.
+Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result and its enclosing durable step.
#### KV Cache effect
diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md
index 9d983f95f4..7ad68759ab 100644
--- a/packages/context/workspace-context/README.zh.md
+++ b/packages/context/workspace-context/README.zh.md
@@ -8,7 +8,7 @@
每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1,并共同抵达第一次请求。reject 或空的第一步决策会将基线留在 agent 的 `next-step` inbox,等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。恢复后的会话会保留一条兼容的可见基线,并只追加当前文件的转换;如果发现、优先级、项目根目录或预算标识发生变化,则会将一条明确取代旧基线的完整基线折入进入步骤的批次。
-该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。
+该插件还会观察第一方 `read`、`write` 和 `edit` 调用成功后产生的不可变 `tools/result`。每个已接受的 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件会在 agent inbox 中排入一项新增;已改变文件会排入一项替换;文件消失或成为同一目录中较早候选文件的重复项时,会排入一则移除通知。原生调用与 Code Mode 子分派共享该路径:嵌套 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定;在 agent loop 步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影。打开的步骤之外直接执行工具时,则立即投影。这样无需依赖文件系统时序,也能保持工具调用/结果/步骤的相邻关系。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。
指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并对解析结果执行 stat,因此会跟随路径最后一段的 symlink 到其目标:指向常规文件的链接会加载目标内容,缺失路径或非文件目标(包括指向目录的链接)则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。前缀取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。
@@ -48,7 +48,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
## 状态与刷新
-模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `step/end` 在匹配的动态上下文进入日志之前到达,插件会清除该 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
+模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会等待所有已排队投影完成,再把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。即使后续复合结果被拦截,成功的嵌套文件 touch 也会聚合到父级执行 token 下;顶层结果会将这些 touch 交给当前打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界进入持久历史后释放其暂存的 touch;串行投影会根据可见会话事件和当前 inbox 协调状态,再替换唯一一条待处理工作区上下文。
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。
@@ -129,7 +129,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
#### Token 影响
-每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,Code Mode 将同一消息延迟到外层 `run_code` 结果之后。
+每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,Code Mode 将同一消息延迟至外层 `run_code` 结果及其所属持久步骤之后。
#### KV Cache 影响
diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts
index ac1f2b638e..fee08f2c08 100644
--- a/packages/context/workspace-context/src/index.ts
+++ b/packages/context/workspace-context/src/index.ts
@@ -14,7 +14,7 @@ import { isDeepStrictEqual } from 'node:util'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
-import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
+import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
import {
@@ -85,15 +85,22 @@ export function apply(ctx: Context, config: Config): void {
excludedScopes: ReadonlySet
}>()
const projectionLifecycle = new AbortController()
+ type ProjectionTouch = { agent: Agent; path: string }
+ const executionTouches = new Map()
ctx.effect(
() => () => {
projectionLifecycle.abort(new Error('workspace-context disposed'))
+ executionTouches.clear()
},
'workspace-context.projectionLifecycle',
)
// Emit listeners are not awaited, so each projection must compose against the
// inbox produced by earlier file results for the same agent.
const projectionTails = new WeakMap>()
+ // Execution ancestry and the enclosing durable step are the two commit
+ // boundaries before an asynchronous projection may mutate the agent inbox.
+ const openSteps = new WeakMap()
+ const stepTouches = new WeakMap()
const compose = async (
agent: Agent,
@@ -272,6 +279,46 @@ export function apply(ctx: Context, config: Config): void {
while ((projection = projectionTails.get(agent)) !== undefined) await projection
}
+ const stepIsOpen = (session: Session): boolean => {
+ const known = openSteps.get(session)
+ if (known !== undefined) return known
+ let open = false
+ for (const event of session.events) {
+ if (event.type === 'step/start') open = true
+ else if (event.type === 'step/end' || event.type === 'turn/end') open = false
+ }
+ openSteps.set(session, open)
+ return open
+ }
+
+ const projectTouch = (touch: ProjectionTouch): void => {
+ const session = touch.agent.session
+ if (!stepIsOpen(session)) {
+ queueProjection(touch.agent, touch.path)
+ return
+ }
+ const pending = stepTouches.get(session)
+ if (pending === undefined) stepTouches.set(session, [touch])
+ else pending.push(touch)
+ }
+
+ ctx.on('session/event', (session, event) => {
+ if (event.type === 'step/start') {
+ openSteps.set(session, true)
+ return
+ }
+ if (event.type === 'turn/end') {
+ openSteps.set(session, false)
+ return
+ }
+ if (event.type !== 'step/end') return
+ openSteps.set(session, false)
+ const pending = stepTouches.get(session)
+ if (pending === undefined) return
+ stepTouches.delete(session)
+ for (const touch of pending) queueProjection(touch.agent, touch.path)
+ })
+
ctx.on('agent/pre-step', async (
{ agent, messages, step, signal },
next,
@@ -301,9 +348,20 @@ export function apply(ctx: Context, config: Config): void {
})
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
- if (result.isError || exec.agent === undefined || exec.signal.aborted) return
- const ownPath = filePathFromExecution(exec)
- if (ownPath === undefined) return
- queueProjection(exec.agent, ownPath)
+ const touches = executionTouches.get(exec.token) ?? []
+ executionTouches.delete(exec.token)
+ if (!result.isError && exec.agent !== undefined && !exec.signal.aborted) {
+ const ownPath = filePathFromExecution(exec)
+ if (ownPath !== undefined) touches.push({ agent: exec.agent, path: ownPath })
+ }
+ if (exec.parent !== undefined) {
+ if (touches.length > 0) {
+ const parentTouches = executionTouches.get(exec.parent)
+ if (parentTouches === undefined) executionTouches.set(exec.parent, touches)
+ else parentTouches.push(...touches)
+ }
+ return
+ }
+ for (const touch of touches) projectTouch(touch)
})
}
diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts
index 20bb993447..0af12596cc 100644
--- a/packages/context/workspace-context/tests/workspace-context.spec.ts
+++ b/packages/context/workspace-context/tests/workspace-context.spec.ts
@@ -187,9 +187,11 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
}
}
-function stubToolExecution(input: Omit): ToolExecution {
+function stubToolExecution(
+ input: Omit & { token?: ToolExecutionToken },
+): ToolExecution {
return {
- token: Symbol('workspace-context-test-execution') as ToolExecutionToken,
+ token: input.token ?? Symbol('workspace-context-test-execution') as ToolExecutionToken,
...input,
}
}
@@ -3948,6 +3950,106 @@ describe('dynamic nested workspace context injection', () => {
}
})
+ it('defers a nested file projection until the enclosing step commits', async () => {
+ const root = await tempRepo()
+ const home = await tempRepo()
+ const ctx = new Context()
+ try {
+ await ctx.plugin(RecordingFileSystem)
+ await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
+ const fs = ctx.fs as RecordingFileSystem
+ fs.entries.set(join(root, '.git'), { type: 'directory' })
+ fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
+ const agent = stubAgent(root)
+ const turnStart = agent.session.append('turn/start', { turn: 1 })
+ ctx.emit('session/event', agent.session, turnStart)
+ const stepStart = agent.session.append('step/start', { turn: 1, step: 1 })
+ ctx.emit('session/event', agent.session, stepStart)
+ const outerToken = Symbol('outer-code-run') as ToolExecutionToken
+
+ ctx.emit('tools/result', stubToolExecution({
+ token: Symbol('nested-read') as ToolExecutionToken,
+ parent: outerToken,
+ signal: testToolSignal,
+ callId: CallId('nested-read'),
+ name: 'read',
+ arguments: { file_path: join('pkg', 'file.txt') },
+ agent,
+ }), { content: [], isError: false, value: null })
+ ctx.emit('tools/result', stubToolExecution({
+ token: Symbol('nested-non-file') as ToolExecutionToken,
+ parent: outerToken,
+ signal: testToolSignal,
+ callId: CallId('nested-non-file'),
+ name: 'search',
+ arguments: {},
+ agent,
+ }), { content: [], isError: false, value: null })
+ ctx.emit('tools/result', stubToolExecution({
+ token: Symbol('second-nested-read') as ToolExecutionToken,
+ parent: outerToken,
+ signal: testToolSignal,
+ callId: CallId('second-nested-read'),
+ name: 'read',
+ arguments: { file_path: join('pkg', 'second.txt') },
+ agent,
+ }), { content: [], isError: false, value: null })
+ ctx.emit('tools/result', stubToolExecution({
+ token: outerToken,
+ signal: testToolSignal,
+ callId: CallId('outer-code-run'),
+ name: 'run_code',
+ arguments: {},
+ agent,
+ }), { content: [], isError: false, value: null })
+
+ await syncWorkspaceContext(ctx, agent)
+ expect(agent.inbox.nextStep).toEqual([])
+
+ const stepEnd = agent.session.append('step/end', { turn: 1, step: 1 })
+ ctx.emit('session/event', agent.session, stepEnd)
+ expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
+ .toContain('nested package rule')
+ } finally {
+ await ctx.fiber.dispose()
+ await rm(root, { recursive: true, force: true })
+ await rm(home, { recursive: true, force: true })
+ }
+ })
+
+ it('seeds closed step state from existing session history', async () => {
+ const root = await tempRepo()
+ const home = await tempRepo()
+ const ctx = new Context()
+ try {
+ await ctx.plugin(RecordingFileSystem)
+ const fs = ctx.fs as RecordingFileSystem
+ fs.entries.set(join(root, '.git'), { type: 'directory' })
+ fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
+ const agent = stubAgent(root)
+ agent.session.append('turn/start', { turn: 1 })
+ agent.session.append('step/start', { turn: 1, step: 1 })
+ agent.session.append('step/end', { turn: 1, step: 1 })
+ agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
+
+ ctx.emit('tools/result', stubToolExecution({
+ signal: testToolSignal,
+ callId: CallId('read-after-closed-step'),
+ name: 'read',
+ arguments: { file_path: join('pkg', 'file.txt') },
+ agent,
+ }), { content: [], isError: false, value: null })
+
+ expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
+ .toContain('nested package rule')
+ } finally {
+ await ctx.fiber.dispose()
+ await rm(root, { recursive: true, force: true })
+ await rm(home, { recursive: true, force: true })
+ }
+ })
+
it('ignores failed, aborted, agentless, and non-file final results', async () => {
const ctx = new Context()
try {
From ec556b7cedf51a8f264534033e6d9656dfd82ab4 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sat, 8 Aug 2026 14:52:11 +0800
Subject: [PATCH 4/4] docs: refresh replay config source link
---
docs/config-catalog.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index afc8cd302a..649f425c36 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -891,7 +891,7 @@ export interface ReplayModelConfig {
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
-Source: [`packages/support/llm-replay/src/index.ts:735`](../packages/support/llm-replay/src/index.ts)
+Source: [`packages/support/llm-replay/src/index.ts:744`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-llm-retry`