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