fix(replay): mark local compaction calls

This commit is contained in:
Tianyi Cui
2026-08-08 02:16:17 +08:00
parent d301dbdc7d
commit 8dcfe5c406
29 changed files with 147 additions and 50 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/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

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

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,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', () => {