fix(compact): address PR 110 review findings

Honor cancellation and disposal around async pre-step setup before the loop can open a step or call the model.

Route compaction summarization through agent/request so router agents can select the model, and remove the stale model argument from agent/pre-step.

Document serial events and the approximate convergence bound, regenerate the Cordis catalog, and add regression coverage for router compaction, HMR cleanup, and assembly/pre-step interruption.
This commit is contained in:
Hypatia May
2026-06-29 15:59:52 +08:00
parent c13f25586c
commit 1f35a4446d
18 changed files with 551 additions and 193 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-compact-basic
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and `ctx.llm.stream()` summarization.
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and summarization routed through the agent request pipeline.
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
@@ -11,7 +11,7 @@ The abstract contract states only WHAT compaction does; this backend owns every
- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length).
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. The bound is strict (`>=` rejects) because the token-pressure gate declines only when the estimate is `< threshold` — a post-compaction history sitting exactly at the threshold would re-trigger.
- **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
- **Surface mutation** — `compactRegion()` appends the `compact/start``compact/summary``compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order, no-veto) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`).

View File

@@ -10,7 +10,7 @@
* compaction declines and retries once it closes).
* - **Summarization** — `ctx.llm.stream()` assembled via `BlockAssembler`
* (the single model-call surface; same path the loop uses) with a fixed
* condense-the-history system prompt.
* condense-the-history system prompt routed through `agent/request`.
* - **Surface mutation** — a single `user/message` replace node carries the
* summary; `compact/*` events are log-only lock + provenance records.
* - **Auto-compaction** — an `agent/pre-step` listener delegates to
@@ -153,12 +153,6 @@ function finishError(finish: FinishReason): Error | undefined {
* context.
*/
export class BasicCompactService extends CompactService {
/**
* `summarize()` reads `ctx.llm.stream()`. Declaring `llm` here lets the cordis
* context proxy resolve it when this service loads as a sibling of LlmService:
* without the inject, `this.ctx.llm` cannot be resolved from this fiber and
* compaction throws at runtime (see postmortem 0001).
*/
static inject = ['llm']
/** Resolved configuration (defaults applied). */
@@ -188,11 +182,11 @@ export class BasicCompactService extends CompactService {
// log-only `compact/*` records and the replacement node cleanly outside a
// step, so a crash mid-compaction leaves an inert orphan the turn-repair
// closes — never a half-open step.
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => {
ctx.on('agent/pre-step', async (agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal) => {
try {
const result = await this.compactIfNeeded(agent.session, system, model, signal)
const result = await this.compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)
if (result) {
const after = this.estimateTokens(agent.session.deriveMessages(), system)
const after = this.estimateTokens(agent.session.deriveMessages(), fullSystemPrompt)
ctx.logger.info(
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
@@ -274,8 +268,9 @@ export class BasicCompactService extends CompactService {
}
/**
* Summarize conversation text into content blocks via `ctx.llm.stream()`
* assembled through a `BlockAssembler` (the single model-call surface).
* Summarize conversation text into content blocks via `agent/request` plus
* `ctx.llm.stream()` assembled through a `BlockAssembler` (the single
* model-call surface).
* Override in a subclass for a template or remote summarizer.
*
* Honors the adapter failure contract: an adapter may report a model failure
@@ -286,12 +281,10 @@ export class BasicCompactService extends CompactService {
* Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears
* down the in-flight summarization rather than orphaning the model call.
*/
async summarize(text: string, model: string, signal?: AbortSignal): Promise<ContentBlock[]> {
if (!model) throw new Error('no model available for summarization')
async summarize(text: string, agent: Agent, turn: number, step: number, signal?: AbortSignal): Promise<ContentBlock[]> {
const assembler = new BlockAssembler()
const options: GenerateOptions = {
model,
model: this.config.summarizationModel || agent.options.model || '',
messages: [{
role: 'user',
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
@@ -302,7 +295,11 @@ export class BasicCompactService extends CompactService {
// exactOptionalPropertyTypes: only set `signal` when present — assigning
// `undefined` to an optional `signal?: AbortSignal` is a type error.
if (signal) options.signal = signal
for await (const chunk of this.ctx.llm.stream(options)) {
const request = await this.ctx.waterfall('agent/request', agent, turn, step, options, () => Promise.resolve(options))
if (!request.model) {
throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel, AgentOptions.model, or supply one via the agent/request waterfall')
}
for await (const chunk of this.ctx.llm.stream(request)) {
assembler.push(chunk)
}
@@ -341,13 +338,15 @@ export class BasicCompactService extends CompactService {
* closes).
*/
override async compactIfNeeded(
session: Session,
system: string,
model: string,
agent: Agent,
turn: number,
step: number,
fullSystemPrompt: string,
signal: AbortSignal,
): Promise<CompactionResult | null> {
const session = agent.session
const messages = session.deriveMessages()
const totalTokens = this.estimateTokens(messages, system)
const totalTokens = this.estimateTokens(messages, fullSystemPrompt)
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
if (totalTokens < threshold) return null
@@ -401,14 +400,16 @@ export class BasicCompactService extends CompactService {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cutoffSeq = nodes[keepFromIdx - 1]!.seq
return this.compactRegion(session, firstSeq, cutoffSeq, model, signal)
return this.compactRegion(session, firstSeq, cutoffSeq, agent, turn, step, signal)
}
override async compactRegion(
session: Session,
start: number,
end: number,
model: string,
agent: Agent,
turn: number,
step: number,
signal?: AbortSignal,
): Promise<CompactionResult> {
// Resolve the range by surface POSITION, not numeric seq interval. A prior
@@ -458,8 +459,8 @@ export class BasicCompactService extends CompactService {
// strictly inside the open turn (but outside any step). A manual call on a
// fully-closed session has no turn to enclose the events, so reject rather
// than emit an un-enclosed run.
const turn = this._openTurn(session)
if (turn === null) {
const openTurn = this._openTurn(session)
if (openTurn === null) {
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
}
// Slice the ordered surface nodes [startIdx, endIdx] inclusive — the
@@ -467,13 +468,12 @@ export class BasicCompactService extends CompactService {
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq)
// --- Acquire lock ---
const startEvent = session.append('compact/start', { turn })
const startEvent = session.append('compact/start', { turn: openTurn })
try {
// --- Extract text and summarize ---
const text = this._extractText(session, shadowedSeqs)
const summaryModel = this.config.summarizationModel || model
const summary = await this.summarize(text, summaryModel, signal)
const summary = await this.summarize(text, agent, turn, step, signal)
// Estimate token count of the shadowed content for provenance.
let shadowedTokenCount = 0
@@ -511,7 +511,7 @@ export class BasicCompactService extends CompactService {
// compact/start and here leaves a detectable orphaned lock (a compact/start
// with no matching compact/end) rather than a compact/end that falsely
// claims compaction finished before the surface replacement landed.
const endEvent = session.append('compact/end', { turn })
const endEvent = session.append('compact/end', { turn: openTurn })
return {
startSeq: startEvent.seq,
@@ -526,7 +526,7 @@ export class BasicCompactService extends CompactService {
// Always release the lock — append compact/end with the error so a
// wedged lock is impossible.
const msg = error instanceof Error ? error.message : String(error)
session.append('compact/end', { turn, error: msg })
session.append('compact/end', { turn: openTurn, error: msg })
throw error
}
}

View File

@@ -39,21 +39,20 @@ export const DEFAULTS: ResolvedConfig = {
}
/**
* Apply defaults to a partial config and enforce the single-pass convergence
* Apply defaults to a partial config and enforce the approximate convergence
* invariant.
*
* `summarizationMaxTokens + retainTokens` must be strictly BELOW the compaction
* threshold (`contextWindow * thresholdRatio`). The invariant guarantees that
* after a compaction the derived history — the (bounded) summary plus the
* retained recent tail — is structurally below the threshold, so the very next
* pre-step check passes and a second compaction cannot fire on the same
* content. The bound is strict (`>=` rejects) because `compactIfNeeded` declines
* only when the estimate is `< threshold`: a post-compaction history sitting
* EXACTLY at the threshold would re-trigger on the next check. Without the
* invariant, a too-large summary or retain budget would leave the
* post-compaction history at/over threshold, triggering compaction again and
* again. Pre-release we reject rather than clamp: a config that cannot guarantee
* convergence is a bug at the call site, not something to silently paper over.
* threshold (`contextWindow * thresholdRatio`). The invariant bounds the two
* variable pieces of post-compaction history — the summary and the retained
* recent tail — but it is intentionally approximate: checkpoint framing,
* per-message role overhead, system-prompt size, and the char/4 estimator's
* error can still leave a narrow accepted config near the threshold. The bound
* is strict (`>=` rejects) because `compactIfNeeded` declines only when the
* estimate is `< threshold`: a post-compaction history sitting EXACTLY at the
* threshold would re-trigger on the next check. Pre-release we reject rather
* than clamp: a config that cannot satisfy even this structural bound is a bug
* at the call site, not something to silently paper over.
*
* @throws if `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`.
*/

View File

@@ -29,7 +29,8 @@ class TestCompactService extends BasicCompactService {
return blocks.length * 10
}
override async summarize(text: string, model: string): Promise<ContentBlock[]> {
override async summarize(text: string, agent: Agent): Promise<ContentBlock[]> {
const model = this.config.summarizationModel || agent.options.model || ''
this.summarizeCalls.push({ text, model })
if (this.summarizeError) throw this.summarizeError
return this.mockSummary
@@ -184,7 +185,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 })
const session = toolTurnSession(3)
const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL)
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL)
expect(result).not.toBeNull()
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
// No dangling tool-result: every compacted/retained step stayed whole.
@@ -214,7 +215,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
// Turn stays open.
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 })
const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL)
const result = await compactIfNeeded(svc, s, '', 'm', SIGNAL)
expect(result).toBeNull()
expect(s.events.some(e => e.type === 'compact/start')).toBe(false)
})
@@ -227,7 +228,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const resultSeq = nodes[2]!.seq
// start = the tool/result: its issuing assistant precedes it IN THE SAME STEP,
// so starting here would orphan that assistant's tool-call. end is fine (user).
await expect(svc.compactRegion(session, resultSeq, resultSeq, 'm'))
await expect(compactRegion(svc, session, resultSeq, resultSeq, 'm'))
.rejects.toThrow(/start seq .* is not a balanced boundary/)
expect(userSeq).toBeLessThan(resultSeq) // sanity: ordering as expected
})
@@ -240,7 +241,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const asstSeq = nodes[1]!.seq
// end = the assistant/message: its tool/result follows IN THE SAME STEP, so
// ending here would strand that result. start is fine (the pre-step user).
await expect(svc.compactRegion(session, userSeq, asstSeq, 'm'))
await expect(compactRegion(svc, session, userSeq, asstSeq, 'm'))
.rejects.toThrow(/end seq .* is not a balanced boundary/)
})
@@ -257,7 +258,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const nodes = s.surface.nodes // [user, asst]
const userSeq = nodes[0]!.seq
const asstSeq = nodes[1]!.seq
await expect(svc.compactRegion(s, userSeq, asstSeq, 'm'))
await expect(compactRegion(svc, s, userSeq, asstSeq, 'm'))
.rejects.toThrow(/end seq .* is not a balanced boundary/)
})
@@ -267,7 +268,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const nodes = session.surface.nodes // [user1, asst1, res1, user2, asst2, res2]
const startSeq = nodes[0]!.seq // pre-step user1 (free boundary)
const endSeq = nodes[2]!.seq // res1 = last node of turn 1's closed step
const result = await svc.compactRegion(session, startSeq, endSeq, 'm')
const result = await compactRegion(svc, session, startSeq, endSeq, 'm')
expect(result.shadowedRange).toEqual({ start: startSeq, end: endSeq })
expectNoOrphanToolResults(session.deriveMessages())
})
@@ -277,7 +278,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const session = toolTurnSession(1)
const nodes = session.surface.nodes
const userSeq = nodes[0]!.seq // pre-step user: free boundary both ways
const result = await svc.compactRegion(session, userSeq, userSeq, 'm')
const result = await compactRegion(svc, session, userSeq, userSeq, 'm')
expect(result.shadowedRange).toEqual({ start: userSeq, end: userSeq })
})
@@ -292,7 +293,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
const ctxSeq = nodes[0]!.seq
const result = await svc.compactRegion(s, ctxSeq, ctxSeq, 'm')
const result = await compactRegion(svc, s, ctxSeq, ctxSeq, 'm')
expect(result.shadowedRange).toEqual({ start: ctxSeq, end: ctxSeq })
})
})
@@ -352,7 +353,7 @@ describe('BasicCompactService.compactRegion', () => {
const firstSeq = nodes[0]!.seq
const secondSeq = nodes[1]!.seq
const result = await svc.compactRegion(session, firstSeq, secondSeq, 'test-model')
const result = await compactRegion(svc, session, firstSeq, secondSeq, 'test-model')
expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq])
expect(result.shadowedRange.start).toBe(firstSeq)
@@ -405,7 +406,7 @@ describe('BasicCompactService.compactRegion', () => {
it('throws when start or end are not surface nodes', async () => {
const svc = createTestService()
const session = multiTurnSession(1, 1)
await expect(svc.compactRegion(session, 999, 1000, 'm'))
await expect(compactRegion(svc, session, 999, 1000, 'm'))
.rejects.toThrow(/start seq 999 not found in surface/)
})
@@ -413,7 +414,7 @@ describe('BasicCompactService.compactRegion', () => {
const svc = createTestService()
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
await expect(svc.compactRegion(session, nodes[1]!.seq, nodes[0]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[1]!.seq, nodes[0]!.seq, 'm'))
.rejects.toThrow(/is after end seq .* on the surface/)
})
@@ -422,7 +423,7 @@ describe('BasicCompactService.compactRegion', () => {
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
session.append('compact/start', { turn: 2 })
await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
.rejects.toThrow(/compaction already in progress/)
})
@@ -432,7 +433,7 @@ describe('BasicCompactService.compactRegion', () => {
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
.rejects.toThrow('model unavailable')
const endEvent = session.events.findLast(e => e.type === 'compact/end')
@@ -455,7 +456,7 @@ describe('BasicCompactService.compactRegion', () => {
const session = multiTurnSession(1, 2)
const nodes = session.surface.nodes
await svc.compactRegion(session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
expect(svc.summarizeCalls.length).toBe(1)
const { text, model } = svc.summarizeCalls[0]!
@@ -470,7 +471,7 @@ describe('BasicCompactService.compactRegion', () => {
const session = multiTurnSession(3, 1)
const nodes = session.surface.nodes
const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')
// Provenance (compact/summary) carries the RAW, unframed summary.
expect(result.summary).toEqual([{ type: 'text', text: 'STRUCTURED SUMMARY' }])
@@ -492,7 +493,7 @@ describe('BasicCompactService.compactRegion', () => {
const firstSeq = nodes[0]!.seq
const lastSeq = nodes[nodes.length - 1]!.seq
await svc.compactRegion(session, firstSeq, lastSeq, 'm')
await compactRegion(svc, session, firstSeq, lastSeq, 'm')
expect(svc.summarizeCalls.length).toBe(1)
const { text } = svc.summarizeCalls[0]!
@@ -506,14 +507,14 @@ describe('BasicCompactService.compactIfNeeded', () => {
it('returns null when tokens are under threshold', async () => {
const svc = createTestService({ contextWindow: 128000, thresholdRatio: 0.8 })
const session = multiTurnSession(1, 1)
expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull()
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
})
it('compacts when tokens exceed threshold', async () => {
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 })
const session = multiTurnSession(3, 1) // 6 surface nodes, 10 tokens each = 60
const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL)
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL)
expect(result).not.toBeNull()
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
})
@@ -522,7 +523,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.2, retainTokens: 15 })
const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens
const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL)
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL)
expect(result).not.toBeNull()
const nodes = session.surface.nodes
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
@@ -538,7 +539,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
// summarizationMaxTokens (1) + retainTokens (45) = 46 < threshold 47.
const svc = createTestService({ contextWindow: 470, thresholdRatio: 0.1, retainTokens: 45 })
const session = multiTurnSession(2, 1)
expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull()
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
})
it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => {
@@ -572,7 +573,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
const nodesBefore = s.surface.nodes.length
expect(nodesBefore).toBe(11)
const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL)
const result = await compactIfNeeded(svc, s, '', 'm', SIGNAL)
expect(result).not.toBeNull()
// Early steps of the SAME open turn were shadowed (impossible under layer 2).
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
@@ -587,7 +588,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
it('returns null for an empty surface', async () => {
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 })
const session = new Session(SessionId('empty'))
expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull()
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
})
it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => {
@@ -600,7 +601,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 })
const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet)
const first = await svc.compactIfNeeded(s, '', 'm', SIGNAL)
const first = await compactIfNeeded(svc, s, '', 'm', SIGNAL)
expect(first).not.toBeNull()
// The summary node now heads the surface with a fresh high seq.
const summaryHeadSeq = s.surface.nodes[0]!.seq
@@ -615,7 +616,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' })
s.append('step/end', { turn: 5, step: 1 })
const second = await svc.compactIfNeeded(s, '', 'm', SIGNAL)
const second = await compactIfNeeded(svc, s, '', 'm', SIGNAL)
expect(second).not.toBeNull()
expect(second!.shadowedSeqs.length).toBeGreaterThan(0)
// The fresh open-turn nodes were NOT compacted.
@@ -630,7 +631,7 @@ describe('BasicCompactService replay equivalence', () => {
const session = multiTurnSession(3, 1)
const nodes = session.surface.nodes
await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')
await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')
const derived = session.deriveMessages()
const replayed = new Session(SessionId('replay'), [...session.events])
@@ -646,7 +647,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
const nodes = session.surface.nodes
// Whole step (user → assistant) is a step-aligned region, so the call reaches
// the in-progress check rather than being rejected for splitting a step.
await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
.rejects.toThrow(/compaction already in progress/)
})
@@ -656,7 +657,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
const nodes = session.surface.nodes
session.append('compact/start', { turn: 1 })
session.append('compact/end', { turn: 1 })
const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')
expect(result).toBeDefined()
})
@@ -679,7 +680,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
const nodes = s.surface.nodes
// The stale start is before the turn/end, so it is NOT seen as in-progress.
const result = await svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm')
const result = await compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm')
expect(result).toBeDefined()
})
})
@@ -829,12 +830,37 @@ function stubAgent(session: Session, model?: string): Agent {
return { session, options: { model } } as unknown as Agent
}
function compactIfNeeded(
svc: BasicCompactService,
session: Session,
fullSystemPrompt: string,
model: string,
signal: AbortSignal,
) {
return svc.compactIfNeeded(stubAgent(session, model), 1, 1, fullSystemPrompt, signal)
}
function compactRegion(
svc: BasicCompactService,
session: Session,
start: number,
end: number,
model: string,
signal?: AbortSignal,
) {
return svc.compactRegion(session, start, end, stubAgent(session, model), 1, 1, signal)
}
function summarize(svc: BasicCompactService, text: string, model: string) {
return svc.summarize(text, stubAgent(new Session(SessionId('summary')), model), 1, 1)
}
describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
it('summarizes via the registered adapter and returns its content', async () => {
const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT')
const svc = new BasicCompactService(ctx, { auto: false, summarizationMaxTokens: 512 })
const summary = await svc.summarize('User: hi\n\nAssistant: hello', 'test-model')
const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model')
expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }])
// The fixed system prompt and maxTokens flow through.
expect(adapter.lastOptions!.system).toContain('compaction engine')
@@ -846,19 +872,19 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
it('throws when no model is provided', async () => {
const { ctx } = await ctxWithModel('x')
const svc = new BasicCompactService(ctx, { auto: false })
await expect(svc.summarize('text', '')).rejects.toThrow(/no model available/)
await expect(summarize(svc, 'text', '')).rejects.toThrow(/no model available/)
})
it('rethrows when the stream ends with a finish-error chunk', async () => {
const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' })
const svc = new BasicCompactService(ctx, { auto: false })
await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' })
await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' })
})
it('rethrows a finish-error chunk without a code (code stays undefined)', async () => {
const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' })
const svc = new BasicCompactService(ctx, { auto: false })
const error = await svc.summarize('text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string })
const error = await summarize(svc, 'text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string })
expect(error?.message).toBe('opaque failure')
expect(error?.code).toBeUndefined()
})
@@ -866,13 +892,13 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
it('rethrows when the stream ends with a finish-aborted chunk', async () => {
const ctx = await ctxWithFinish({ kind: 'aborted' })
const svc = new BasicCompactService(ctx, { auto: false })
await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' })
await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' })
})
it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => {
const ctx = await ctxWithFinish({ kind: 'max-tokens' })
const svc = new BasicCompactService(ctx, { auto: false })
await expect(svc.summarize('text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' })
await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' })
})
it('compactRegion leaves the surface intact when summarization hits max-tokens', async () => {
@@ -882,7 +908,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
const before = [...session.surface.nodes]
const nodes = session.surface.nodes
await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model'))
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model'))
.rejects.toMatchObject({ code: 'MAX_TOKENS' })
// No replacement landed — the surface is byte-identical, and the lock was
@@ -899,7 +925,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')
expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }])
// The raw summary is wrapped in the checkpoint framing on the surface.
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' })
@@ -908,8 +934,8 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => {
/** Fire the agent/pre-step serial checkpoint as the loop does. */
function firePreStep(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise<unknown> {
return ctx.serial('agent/pre-step', agent, 1, step, system, model, SIGNAL)
function firePreStep(ctx: Context, agent: Agent, step: number, fullSystemPrompt: string): Promise<unknown> {
return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, SIGNAL)
}
it('compacts (mutating the surface) when over threshold', async () => {
@@ -919,7 +945,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
const agent = stubAgent(session, 'test-model')
const before = session.surface.nodes.length
await firePreStep(ctx, agent, 1, '', 'test-model')
await firePreStep(ctx, agent, 1, '')
// The surface shrank in place, and a summary checkpoint landed.
expect(session.surface.nodes.length).toBeLessThan(before)
@@ -936,7 +962,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
// A step-2 checkpoint (a tool-heavy turn's later step) must still compact —
// the surface accumulated assistant/message + tool/result nodes since step 1.
await firePreStep(ctx, agent, 2, '', 'test-model')
await firePreStep(ctx, agent, 2, '')
expect(session.events.some(e => e.type === 'compact/start')).toBe(true)
})
@@ -946,7 +972,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
const session = multiTurnSession(1, 1)
const agent = stubAgent(session, 'test-model')
await firePreStep(ctx, agent, 1, '', 'test-model')
await firePreStep(ctx, agent, 1, '')
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
})
@@ -960,7 +986,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
const agent = stubAgent(session, 'missing-model')
const before = session.surface.nodes.length
await firePreStep(ctx, agent, 1, '', 'missing-model')
await firePreStep(ctx, agent, 1, '')
// No summary landed; the surface is unchanged.
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
expect(session.surface.nodes.length).toBe(before)
@@ -972,9 +998,44 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
const session = multiTurnSession(3, 1)
const agent = stubAgent(session, 'test-model')
await firePreStep(ctx, agent, 1, '', 'test-model')
await firePreStep(ctx, agent, 1, '')
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
})
it('routes summarization through agent/request so router agents can choose the model', async () => {
const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model')
ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
options.model = 'routed-model'
return next()
})
void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 })
const session = multiTurnSession(5, 1)
const agent = stubAgent(session)
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
expect(adapter.lastOptions?.model).toBe('routed-model')
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'ROUTED SUMMARY' })
})
it('removes the auto pre-step listener when the plugin fiber is disposed', async () => {
const { ctx } = await ctxWithModel('SUMMARY')
const fiber = await ctx.plugin(BasicCompactService, {
contextWindow: 200,
thresholdRatio: 0.5,
retainTokens: 20,
summarizationMaxTokens: 50,
})
const session = multiTurnSession(5, 1)
const agent = stubAgent(session, 'test-model')
await fiber.dispose()
await firePreStep(ctx, agent, 1, '')
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
expect(ctx.get('compact')).toBeUndefined()
})
})
describe('BasicCompactService._extractText branches', () => {
@@ -1001,7 +1062,7 @@ describe('BasicCompactService._extractText branches', () => {
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
const { text } = svc.summarizeCalls[0]!
expect(text).toContain('[Context: project context here]')
@@ -1030,7 +1091,7 @@ describe('BasicCompactService._extractText branches', () => {
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
expect(svc.summarizeCalls[0]!.text).toContain('Tool error (call c9): boom failure')
})
})
@@ -1064,7 +1125,7 @@ describe('BasicCompactService edge cases', () => {
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
const { text } = svc.summarizeCalls[0]!
expect(text).toContain('[tool-result: [image]]') // nested tool-result with content
expect(text).toContain('[custom-widget]') // unknown block placeholder
@@ -1089,7 +1150,7 @@ describe('BasicCompactService edge cases', () => {
const session = multiTurnSession(4, 1)
const agent = stubAgent(session, 'test-model')
await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL)
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
// The surface was mutated; the head message is the framed summary checkpoint.
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
@@ -1111,7 +1172,7 @@ describe('BasicCompactService edge cases', () => {
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const nodes = s.surface.nodes
await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm'))
.rejects.toThrow(/no open turn/)
// The lock was never acquired — no compact/start landed.
expect(s.events.some(e => e.type === 'compact/start')).toBe(false)
@@ -1126,7 +1187,7 @@ describe('BasicCompactService edge cases', () => {
s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const nodes = s.surface.nodes
await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm'))
await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[0]!.seq, 'm'))
.rejects.toThrow(/no open turn/)
expect(s.events.some(e => e.type === 'compact/start')).toBe(false)
})
@@ -1136,14 +1197,14 @@ describe('BasicCompactService edge cases', () => {
const session = new Session(SessionId('empty-but-pressured'))
// No surface nodes, but a large system prompt pushes the estimate over threshold.
const bigPrompt = 'x'.repeat(800) // ceil(800/4) = 200 tokens >> threshold 100
expect(await svc.compactIfNeeded(session, bigPrompt, 'm', SIGNAL)).toBeNull()
expect(await compactIfNeeded(svc, session, bigPrompt, 'm', SIGNAL)).toBeNull()
})
it('compactRegion throws when end is not a surface node (start valid)', async () => {
const svc = createTestService()
const session = multiTurnSession(1, 1)
const nodes = session.surface.nodes
await expect(svc.compactRegion(session, nodes[0]!.seq, 9999, 'm'))
await expect(compactRegion(svc, session, nodes[0]!.seq, 9999, 'm'))
.rejects.toThrow(/end seq 9999 not found in surface/)
})
@@ -1155,7 +1216,7 @@ describe('BasicCompactService edge cases', () => {
const nodes = session.surface.nodes
// Whole step (user → assistant): a step-aligned region that reaches summarize.
await expect(svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure')
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure')
const endEvent = session.events.findLast(e => e.type === 'compact/end')!
expect(endEvent.data).toMatchObject({ error: 'plain string failure' })
})
@@ -1170,7 +1231,7 @@ describe('BasicCompactService edge cases', () => {
const agent = stubAgent(session, 'test-model')
const before = session.surface.nodes.length
await ctx.serial('agent/pre-step', agent, 1, 1, '', 'test-model', SIGNAL)
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
// The failure was swallowed; the surface is untouched and a warning logged.
expect(session.surface.nodes.length).toBe(before)
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
@@ -1187,7 +1248,7 @@ describe('BasicCompactService edge cases', () => {
const agent = stubAgent(session, 'test-model')
const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200
await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, 'test-model', SIGNAL)
await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, SIGNAL)
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
expect(svc.summarizeCalls.length).toBe(0)
})
@@ -1221,7 +1282,7 @@ describe('BasicCompactService edge cases', () => {
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
// Every empty-content message (user text, empty reasoning, empty-content
// tool/result, empty context, empty steering) extracted to nothing and was
// skipped — the only surviving line is the assistant's tool-call (which a
@@ -1256,7 +1317,7 @@ describe('BasicCompactService edge cases', () => {
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await svc.compactRegion(s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
const { text } = svc.summarizeCalls[0]!
// Every non-text block surfaces as a placeholder rather than being dropped.
expect(text).toContain('User: [image]')
@@ -1280,7 +1341,7 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
// First compaction: shadow the two oldest surface nodes.
const nodes0 = session.surface.nodes
const first = await svc.compactRegion(session, nodes0[0]!.seq, nodes0[1]!.seq, 'm')
const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm')
// The summary node now sits at the head with a seq HIGHER than the
// retained older nodes that follow it — the non-monotonic surface. (The
@@ -1298,7 +1359,7 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
const startSeq = nodes1[0]!.seq
const endSeq = nodes1[2]!.seq
expect(startSeq).toBeGreaterThan(endSeq)
const second = await svc.compactRegion(session, startSeq, endSeq, 'm')
const second = await compactRegion(svc, session, startSeq, endSeq, 'm')
// Exactly the three nodes at surface positions [0..2] are shadowed, in
// surface order — the positional slice, regardless of their seq values.
@@ -1316,14 +1377,14 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
// First compaction shadows the oldest two surface nodes, landing a high-seq
// summary node at the head.
const n0 = session.surface.nodes
await svc.compactRegion(session, n0[0]!.seq, n0[1]!.seq, 'm')
await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'm')
// Second compaction spans [head summary … turn-2's step end]. The head's seq
// is higher than the older retained nodes' seqs, so a log-seq-order walk
// would emit the older messages BEFORE the checkpoint.
const n1 = session.surface.nodes
svc.summarizeCalls = []
await svc.compactRegion(session, n1[0]!.seq, n1[2]!.seq, 'm')
await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'm')
// The extracted transcript follows surface order: the checkpoint (head)
// first, then the older retained messages — matching deriveMessages().
@@ -1355,7 +1416,7 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => {
const svc = ctx.compact as BasicCompactService
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')
expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }])
// Tear the fiber down so this test owns no leaked registration; the
@@ -1403,7 +1464,7 @@ describe('BasicCompactService under the real invariants plugin', () => {
const nodes = session.surface.nodes
// No invariant throws here: compact/* + the replacement are all in turn 3.
const result = await svc.compactRegion(session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')
expect(result.shadowedSeqs.length).toBe(2)
expect(session.surface.nodes[0]!.seq).toBeGreaterThan(session.surface.nodes[1]!.seq)
})
@@ -1416,15 +1477,14 @@ describe('BasicCompactService under the real invariants plugin', () => {
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
const n0 = session.surface.nodes
await svc.compactRegion(session, n0[0]!.seq, n0[1]!.seq, 'test-model')
await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'test-model')
// Surface head now carries a higher seq than the older retained nodes. A
// second compaction spanning [head … a later closed-step end] must pass the
// invariants' positional replace check even though startSeq > endSeq.
const n1 = session.surface.nodes
expect(n1[0]!.seq).toBeGreaterThan(n1[2]!.seq)
const second = await svc.compactRegion(session, n1[0]!.seq, n1[2]!.seq, 'test-model')
const second = await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'test-model')
expect(second.shadowedSeqs).toEqual([n1[0]!.seq, n1[1]!.seq, n1[2]!.seq])
})
})