Merge origin/master into codex/dsh-badge-plugin

This commit is contained in:
Tianyi Cui
2026-08-08 16:03:30 +08:00
38 changed files with 385 additions and 97 deletions

View File

@@ -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: 4241899788998a744801bb0406a15ecd70af6401
README.zh.md: c1df4afaa1837a3b689da80cc1b49df43f60e513

View File

@@ -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()` and requires complete `rawOutput`, while unmarked `rawOutput` does not identify the call path. The transaction preserves those fields on `compact/summary`.
## Config (`BasicCompactConfig`)

View File

@@ -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`;未带标记的 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。
## 配置(`BasicCompactConfig`

View File

@@ -43,7 +43,7 @@ interface PreparedCompaction extends SurfaceSelection {
readonly input: SummarizationInput
}
interface SummarizedCompaction extends PreparedCompaction, SummaryResult {
type SummarizedCompaction = PreparedCompaction & SummaryResult & {
readonly checkpointMessage: UserMessage
}
@@ -415,16 +415,18 @@ function commitCompactionBody(
shadowedSeqs,
shadowedTokenCount,
summary,
rawOutput,
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 },
...callProvenance,
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,

View File

@@ -85,16 +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. */
rawOutput?: ContentBlock[]
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
@@ -162,6 +173,7 @@ export async function summarizeWithLlm(
return {
summary,
rawOutput,
llmStreamCall: true,
provider: options.provider,
model: options.model,
maxTokens: config.maxTokens,

View File

@@ -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,
@@ -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('<compacted-summary>')
@@ -1165,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<SummaryResult>()
})
it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => {
const { adapter, compact } = await summarizerHarness([
{ type: 'reasoning', text: 'private' },
@@ -1187,6 +1197,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 +1311,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',
})

View File

@@ -28,8 +28,6 @@ declare module '@deepseek-ai/dsh-session' {
*/
'compact/summary': {
summary: ContentBlock[]
/** Complete provider output before the backend's safe summary projection. */
rawOutput?: ContentBlock[]
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
shadowedTokenCount: number
@@ -46,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.

View File

@@ -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

View File

@@ -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 `<system-reminder>` 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

View File

@@ -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 影响

View File

@@ -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<string>
}>()
const projectionLifecycle = new AbortController()
type ProjectionTouch = { agent: Agent; path: string }
const executionTouches = new Map<ToolExecutionToken, ProjectionTouch[]>()
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<Agent, Promise<void>>()
// 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<Session, boolean>()
const stepTouches = new WeakMap<Session, ProjectionTouch[]>()
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)
})
}

View File

@@ -187,9 +187,11 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
}
}
function stubToolExecution(input: Omit<ToolExecution, 'token'>): ToolExecution {
function stubToolExecution(
input: Omit<ToolExecution, 'token'> & { 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 {

View File

@@ -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

View File

@@ -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 (`<scenario>/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 (`<scenario>/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.

View File

@@ -8,7 +8,7 @@
## fixture 的工作方式
fixture 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 agent-loop `stream()` 调用的分片序列。压缩compaction摘要器成功时日志记录方式有所不同`compact/summary` 携带完整的 `rawOutput` 时,回放会在该事件的位置重建一条规范成功流,其中每个块各使用一对 `block-start`/`block-end`,带上已记录的 usage如有并以 `stop` 终止。提供方增量的精确切分不属于持久压缩结果。不带 `rawOutput` 的摘要并不意味着发生了 LLM 调用,因为模板摘要器和远程摘要器可能不经本地适配器生成该摘要
fixture 就是持久化的会话日志(`<scenario>/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 就是持久化的会话日志(`<scenario>/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 就是持久化的会话日志(`<scenario>/session.jsonl`)。其 `as
## 已知限制与暂缓事项
- **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut 会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。
- **只有普通 loop 分片和已完成的压缩输出才能派生**:在产生分片前直接抛出异常取消/挂起场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。
- **只有普通 loop 分片和带标记的本地压缩输出才能派生**:在产生分片前直接抛出异常取消/挂起,或未标记的外部摘要器调用场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。

View File

@@ -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[] }
@@ -177,8 +179,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,13 +207,23 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
close(currentKey, current)
currentKey = undefined
current = []
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 })
}

View File

@@ -202,6 +202,7 @@ describe('deriveReplayScript', () => {
data: {
summary: rawOutput,
rawOutput,
llmStreamCall: true,
shadowedRange: { start: 1, end: 1 },
shadowedSeqs: [1],
shadowedTokenCount: 20,
@@ -238,6 +239,49 @@ 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 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 === undefined ? [] : [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 +291,7 @@ describe('deriveReplayScript', () => {
data: {
summary: [block],
rawOutput: [block],
llmStreamCall: true,
shadowedRange: { start: 1, end: 1 },
shadowedSeqs: [1],
shadowedTokenCount: 20,
@@ -289,6 +334,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', () => {