fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001)

Codex round 1 CBR-001: a head-anchored compaction checkpoint was
mis-classified by the log-position step-alignment scan, so a second
auto-compaction over a checkpoint-headed surface silently failed.

Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a
`replace` op lands a checkpoint at a high log seq whose SURFACE position
is the head — its log neighbours (the open step's assistant/message) are
not its surface neighbours, so the forward scan wrongly reported mid-step.

Fix, per the agreed direction:
- Replace the two log-position predicates with one surface-anchored
  helper `isToolPairingBalanced(nodes, events, beforeSeq)` in
  `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is
  balanced when no unanswered tool-call precedes it on the surface; a
  region is collapsible iff both edges are balanced cuts. The open-tail
  and free-node cases fall out of the same counter. It also throws on a
  corrupt surface (a tool/result with no matching call).
- Move compaction off the in-step seam to a new "pre-step" seam fired
  after turn/start and before step/start, so a compaction's log-only
  compact/* records and its replacement node land cleanly OUTSIDE any
  step (the honest structure crash-safety relies on). Renamed the event
  agent/pre-request → agent/pre-step and switched its dispatch from
  parallel → serial (listeners mutate the surface as a side effect;
  serial isolates them so concurrent appends can't interleave). Extended
  the catalog generator to accept @mode serial.

Regression coverage: a real-loop test driving an auto-compaction asserts
the landed checkpoint is a balanced cut on both sides; unit tests pin the
checkpoint case, the mid-step injection case, multi-call steps, and the
corrupt-surface guard. Proven red on the old log-position logic.
This commit is contained in:
Hypatia May
2026-06-26 13:51:01 +08:00
parent cec32faa4e
commit d6da8ca29a
17 changed files with 912 additions and 448 deletions

View File

@@ -19,7 +19,7 @@ export { isJsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { isStepAlignedStart, isStepAlignedEnd } from './step-boundary.ts'
export { isToolPairingBalanced } from './tool-pairing.ts'
declare module 'cordis' {
interface Context {

View File

@@ -1,97 +0,0 @@
/**
* Step-boundary predicates over a session log: is a given surface node a SAFE
* place to start or end a region that will be collapsed (e.g. by compaction)?
*
* The invariant a consumer needs: a collapsed region must NOT partially overlap
* a step. A step's surface nodes form a contiguous run, and a region must
* contain either ALL of a step's nodes or NONE of them — otherwise it can split
* an `assistant/message`'s `tool-call` blocks from their `tool/result`s, leaving
* the rehydrated transcript with a dangling tool-call or an orphaned tool-result
* (which every provider rejects). This is the compaction-time mirror of the
* crash-recovery imbalance that {@link interruptedTurnClosers} repairs on load.
*
* Nodes that belong to NO step — a pre-step `user/message` (drained before the
* first `step/start`), inter-step `steering/message`, or an injection
* `context/message` (wrapped in a bare `turn/start → context/message → turn/end`
* with no step) — carry no tool pairing and are free boundaries on both sides.
*
* The scans classify each neighbor event into three buckets: a turn/step
* BOUNDARY marker (the region edge is clean), a SURFACE node (the region edge
* is mid-step), or NOISE to skip (`assistant/chunk`, the log-only `compact/*`
* records, and any future non-surface event). "Surface node" is decided by the
* shared {@link isSurfaceEvent} guard so the two notions can't drift.
*
* @module @deepseek-ai/dsh-session/step-boundary
*/
import type { SessionEvent } from './types.ts'
import { isSurfaceEvent } from './surface.ts'
/** Turn/step boundary marker types — the walls the scans stop on. */
const BOUNDARY_TYPES = new Set<string>(['turn/start', 'turn/end', 'step/start', 'step/end'])
/**
* Whether the surface node at `seq` is a SAFE START for a collapsed region —
* i.e. it is the first surface node of its step, or it belongs to no step at
* all (a free inter-step / pre-step / injection node).
*
* Scans BACKWARD from `seq`, skipping noise, and stops at the first significant
* event: a turn/step boundary marker ⇒ aligned (nothing of `seq`'s step lies
* before it), a surface node ⇒ NOT aligned (a predecessor surface node sits in
* the same step, so starting here would orphan it), start-of-log ⇒ aligned.
*
* No open-step check is needed on the start side: an open (unclosed) step can
* only ever be the LAST turn's last step, never before a valid region start.
*/
export function isStepAlignedStart(events: readonly SessionEvent[], seq: number): boolean {
for (let i = seq - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = events[i]!
if (BOUNDARY_TYPES.has(event.type)) return true
if (isSurfaceEvent(event)) return false
}
return true
}
/**
* Whether the surface node at `seq` is a SAFE END for a collapsed region —
* i.e. it is the last surface node of a CLOSED step, or it belongs to no step
* at all.
*
* Scans FORWARD from `seq`, skipping noise, and stops at the first significant
* event: a turn/step boundary marker ⇒ aligned (the step/turn closes after
* `seq`, or a new one begins because `seq` was inter-step), a surface node ⇒
* NOT aligned (a later surface node sits in the same step). Reaching
* end-of-log is aligned ONLY when `seq` is not inside an OPEN step — an open
* trailing step's `tool-call`s have no `tool/result`s yet, so collapsing it
* would defer the orphan to when those results land later. {@link isInOpenStep}
* decides that via a backward scan.
*/
export function isStepAlignedEnd(events: readonly SessionEvent[], seq: number): boolean {
for (let i = seq + 1; i < events.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = events[i]!
if (BOUNDARY_TYPES.has(event.type)) return true
if (isSurfaceEvent(event)) return false
}
// End of log: aligned only if `seq` is not inside a still-open step.
return !isInOpenStep(events, seq)
}
/**
* Whether `seq` sits inside an OPEN step — a `step/start` with no later
* `step/end`. Only meaningful at the tail (the EOL branch of
* {@link isStepAlignedEnd}): scans BACKWARD for the nearest turn/step boundary.
* The nearest one being `step/start` means a step opened before `seq` and never
* closed (no `step/end` lies after `seq`, or the forward scan would not have
* reached EOL) — so `seq` is mid-open-step. Any other nearest boundary (or none)
* means `seq` is inter-step / pre-step.
*/
function isInOpenStep(events: readonly SessionEvent[], seq: number): boolean {
for (let i = seq - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const type = events[i]!.type
if (BOUNDARY_TYPES.has(type)) return type === 'step/start'
}
return false
}

View File

@@ -0,0 +1,100 @@
/**
* Tool-pairing balance over a session's SURFACE: is a given cut point in the
* surface a safe edge for a collapsed region (e.g. compaction)?
*
* The invariant a consumer needs: a collapsed region must never separate an
* `assistant/message`'s `tool-call` blocks from their answering `tool/result`s
* — that would leave the rehydrated transcript with a dangling tool-call or an
* orphaned tool-result, which every provider rejects. (This is the
* compaction-time mirror of the crash-recovery imbalance that
* {@link interruptedTurnClosers} repairs on load.) Steps were once used as a
* proxy for this bracketing, but a compaction REWRITES the surface — it lands a
* replacement node at a high log seq whose SURFACE position is the head — so a
* scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The
* pairing the invariant actually protects lives in the surface nodes' own
* content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels
* with the node through any reshaping, so alignment is decided over the surface
* directly.
*
* A **cut** is a gap between two adjacent surface nodes (named by the node it
* sits immediately before), or the after-tail gap (`null`). Walking the surface
* head→tail and assigning each node a delta — `+1` per `tool-call` block on an
* `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a
* cut is the number of still-unanswered tool calls before it. A cut is
* **balanced** when that depth is `0`. A region `[start..end]` is safe to
* collapse iff BOTH its edges are balanced cuts: the cut before `start` and the
* cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an
* inter-step `steering/message`, an injection `context/message`) carry no
* pairing, contribute `0`, and so are free boundaries — exactly as before, but
* now as a consequence of the balance rather than a special case. An open
* trailing step (an assistant whose `tool/result`s have not landed yet) keeps
* the depth positive through the tail, so no cut inside it is balanced — the
* old explicit open-step check falls out of the same counter.
*
* @module @deepseek-ai/dsh-session/tool-pairing
*/
import type { SessionEvent } from './types.ts'
import type { SurfaceNode } from './surface.ts'
/**
* The tool-pairing delta of a surface node: how it shifts the count of
* unanswered tool calls. An `assistant/message` opens one bracket per
* `tool-call` block; a `tool/result` closes one; every other surface node
* (`user/message`, `context/message`, `steering/message`, a usage-only
* `assistant/message` with no tool-call blocks) is pairing-neutral.
*/
function nodeDelta(event: SessionEvent): number {
switch (event.type) {
case 'assistant/message':
return event.data.content.filter(block => block.type === 'tool-call').length
case 'tool/result':
return -1
// Non-pairing surface nodes and every non-surface event contribute nothing.
default:
return 0
}
}
/**
* Whether the surface prefix ending at the given cut has BALANCED tool-call /
* tool-result brackets — i.e. every `tool-call` block on the surface before the
* cut has its answering `tool/result` before the cut too, so the cut is a safe
* edge for a collapsed region (it cannot split an assistant↔result pair).
*
* `nodes` is the surface linked list in head→tail order (e.g.
* `session.surface.nodes`); `events` is the session log, used to look each
* node's event up by `seq`. `beforeSeq` names the cut by the surface node it
* sits immediately before; the after-tail cut (the whole surface) is `null`,
* as is any `beforeSeq` not present on the surface.
*
* A region `[start..end]` is collapsible iff both edges are balanced cuts: call
* `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and
* `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
* for the cut after `end`.
*
* @throws if the surface prefix drives the unanswered-call depth negative — a
* `tool/result` with no preceding open `tool-call` on the surface. That is a
* corrupt surface (a structural invariant violation), surfaced loudly here
* rather than silently mis-classifying a boundary.
*/
export function isToolPairingBalanced(
nodes: readonly SurfaceNode[],
events: readonly SessionEvent[],
beforeSeq: number | null,
): boolean {
let depth = 0
for (const node of nodes) {
if (node.seq === beforeSeq) return depth === 0
// node.seq is a surface-node seq, always a valid log index by construction.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
depth += nodeDelta(events[node.seq]!)
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
}
// Reached the after-tail cut (beforeSeq === null, or a seq not on the
// surface): the whole-surface prefix is balanced iff depth returned to 0.
return depth === 0
}

View File

@@ -1,172 +0,0 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { isStepAlignedStart, isStepAlignedEnd } from '../src/index.ts'
import type { SessionEvent } from '../src/index.ts'
/**
* Unit coverage for the step-alignment predicates. They decide whether a
* surface node is a safe START / END for a collapsed region (compaction): a
* region must contain whole steps, never split an `assistant/message`'s
* tool-calls from their `tool/result`s. Nodes belonging to no step (pre-step
* user message, inter-step steering, injection context) are free boundaries.
*
* Builders mirror the agent loop's real append order so the fixtures are
* representative: queued user messages land BEFORE `step/start`; within a step
* the order is `assistant/message` then `tool/result`(s); injection turns are a
* bare `turn/start → context/message → turn/end` with no step.
*/
const SURFACE = { surfaceOp: 'append' as const }
/** A closed turn with one closed step holding an assistant + its tool result. */
function toolStepLog(): SessionEvent[] {
return [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE },
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [
{ type: 'text', text: 'calling' },
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
] }, ...SURFACE },
{ type: 'tool/call', seq: 4, time: 4, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' } },
{ type: 'tool/result', seq: 5, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, ...SURFACE },
{ type: 'step/end', seq: 6, time: 6, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 7, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
]
}
describe('isStepAlignedStart', () => {
it('is true for a pre-step user/message (belongs to no step)', () => {
// seq 1 user/message sits before step/start at seq 2 → free boundary.
expect(isStepAlignedStart(toolStepLog(), 1)).toBe(true)
})
it('is true for the first surface node of a step (the assistant/message)', () => {
// Backward from seq 3 the first significant event is step/start → aligned.
expect(isStepAlignedStart(toolStepLog(), 3)).toBe(true)
})
it('is false for a tool/result whose assistant/message precedes it in the same step', () => {
// Backward from seq 5 the first significant event is the assistant/message
// surface node (seq 3) → starting here would orphan that assistant's call.
expect(isStepAlignedStart(toolStepLog(), 5)).toBe(false)
})
it('is true at start-of-log (nothing precedes)', () => {
const log: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE },
]
expect(isStepAlignedStart(log, 0)).toBe(true)
})
it('skips noise (assistant/chunk, compact/* records) when scanning back', () => {
// A compacted region landed compact/* log-only records between the prior
// step boundary and this surface node; they must be skipped, not treated as
// walls. Backward from seq 4 skips compact/end, compact/summary, compact/start
// and stops at step/start (seq 0) → aligned.
const log: SessionEvent[] = [
{ type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } },
{ type: 'compact/start', seq: 1, time: 1, data: { turn: 1 } } as unknown as SessionEvent,
{ type: 'compact/summary', seq: 2, time: 2, data: { summary: [], shadowedRange: { start: 0, end: 0 }, shadowedSeqs: [], shadowedTokenCount: 0 } } as unknown as SessionEvent,
{ type: 'compact/end', seq: 3, time: 3, data: { turn: 1 } } as unknown as SessionEvent,
{ type: 'assistant/message', seq: 4, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE },
]
expect(isStepAlignedStart(log, 4)).toBe(true)
})
})
describe('isStepAlignedEnd', () => {
it('is true for the last surface node of a closed step (the tool/result)', () => {
// Forward from seq 5 the first significant event is step/end → aligned.
expect(isStepAlignedEnd(toolStepLog(), 5)).toBe(true)
})
it('is false for an assistant/message with a later tool/result in the same step', () => {
// Forward from seq 3 the first significant event is the tool/result surface
// node (seq 5) → ending here would strand that result.
expect(isStepAlignedEnd(toolStepLog(), 3)).toBe(false)
})
it('is true for a pre-step user/message (next significant event is step/start)', () => {
expect(isStepAlignedEnd(toolStepLog(), 1)).toBe(true)
})
it('is false at EOL when the node is inside an open (unclosed) step', () => {
// step/start then an assistant tool-call, but no step/end / tool/result yet
// (mid-flight). Ending the region on seq 3 would summarize away a tool-call
// whose result lands later → orphan. EOL + open step ⇒ not aligned.
const log: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
] }, ...SURFACE },
]
expect(isStepAlignedEnd(log, 2)).toBe(false)
})
it('is false at EOL when the node is inside an open step, skipping noise on the back-scan', () => {
// The open-step back-scan must skip non-boundary events (here an
// assistant/chunk) before it reaches step/start. Without the skip it would
// mis-read the chunk as the nearest "boundary" and never confirm the open step.
const log: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } },
{ type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
] }, ...SURFACE },
]
expect(isStepAlignedEnd(log, 3)).toBe(false)
})
it('is true at EOL when the node is a trailing inter-step node (step already closed)', () => {
// A steering message appended after step/end, at the tail. Backward the
// nearest boundary is step/end → not in an open step → aligned.
const log: SessionEvent[] = [
{ type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 1, time: 1, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE },
{ type: 'step/end', seq: 2, time: 2, data: { turn: 1, step: 1 } },
{ type: 'steering/message', seq: 3, time: 3, data: { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, ...SURFACE },
]
expect(isStepAlignedEnd(log, 3)).toBe(true)
})
it('is true at EOL when no step ever opened (start-of-log fallback in open-step check)', () => {
// A lone surface node, no turn/step markers at all → not in an open step.
const log: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE },
]
expect(isStepAlignedEnd(log, 0)).toBe(true)
})
it('skips noise (assistant/chunk) when scanning forward', () => {
// assistant/chunk events precede the assistant/message in a real step; the
// forward scan from an inter-step node must skip them and stop on step/start.
const log: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE },
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } },
]
// Forward from seq 0 hits step/start at seq 1 → aligned (noise after is moot).
expect(isStepAlignedEnd(log, 0)).toBe(true)
})
})
describe('step-alignment on an injection turn (no step)', () => {
// An idle inject() wraps a context/message in a bare turn/start → context/message
// → turn/end with NO step/start. The context node is a free boundary both ways.
const injectionLog = (): SessionEvent[] => [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } },
{ type: 'context/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, ...SURFACE },
{ type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
]
it('start: aligned (backward hits turn/start)', () => {
expect(isStepAlignedStart(injectionLog(), 1)).toBe(true)
})
it('end: aligned (forward hits turn/end)', () => {
expect(isStepAlignedEnd(injectionLog(), 1)).toBe(true)
})
})

View File

@@ -0,0 +1,314 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
import type { SessionEvent, SurfaceNode } from '../src/index.ts'
/**
* Unit coverage for the tool-pairing balance check. It decides whether a CUT in
* the surface (a gap before a given surface node, or the after-tail gap) is a
* safe edge for a collapsed region (compaction): a region must never split an
* `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced
* when no unanswered tool-call sits before it on the surface. Nodes belonging to
* no step (pre-step user message, inter-step steering, injection context) are
* pairing-neutral, so their cuts are free boundaries.
*
* The fixtures are built through a real {@link Session} so the surface linked
* list is derived exactly as production does — including the non-monotonic
* surface a `replace` op leaves (a compaction checkpoint at a high log seq
* sitting at the surface head), which is the case the abandoned log-position
* scan mis-classified.
*
* Builders mirror the agent loop's real append order: queued user messages land
* BEFORE `step/start`; within a step the order is `assistant/message` then
* `tool/result`(s); injection turns are a bare `turn/start → context/message →
* turn/end` with no step.
*/
const SURFACE = { surfaceOp: 'append' as const }
/** Surface nodes + log for a session, the two args the balance check takes. */
function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } {
return { nodes: session.surface.nodes, events: session.events }
}
/** The cut BEFORE the surface node at `seq` is balanced (safe region start). */
function startBalanced(session: Session, seq: number): boolean {
const { nodes, events } = surfaceOf(session)
return isToolPairingBalanced(nodes, events, seq)
}
/** The cut AFTER the surface node at `seq` is balanced (safe region end). */
function endBalanced(session: Session, seq: number): boolean {
const { nodes, events } = surfaceOf(session)
const node = nodes.find(n => n.seq === seq)
if (!node) throw new Error(`seq ${seq} is not a surface node`)
return isToolPairingBalanced(nodes, events, node.next)
}
/** Surface seq of the nth (0-based) event of a given type. */
function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number {
return s.events.filter(e => e.type === type)[nth]!.seq
}
/** A closed turn with one closed step holding an assistant + its tool result. */
function toolStepSession(): Session {
const s = new Session(SessionId('tool-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE)
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'text', text: 'calling' },
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
],
}, SURFACE)
s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' })
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
describe('isToolPairingBalanced — region START (cut before a node)', () => {
it('is true for a pre-step user/message (belongs to no step)', () => {
const s = toolStepSession()
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
it('is true for the first surface node of a step (the assistant/message)', () => {
// The cut before the assistant is balanced — nothing unanswered precedes it.
const s = toolStepSession()
expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true)
})
it('is false for a tool/result whose assistant/message precedes it in the same step', () => {
// The cut before the tool/result has one unanswered tool-call (the
// assistant's) → starting the region here would orphan that call.
const s = toolStepSession()
expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false)
})
it('is true at the surface head (nothing precedes)', () => {
const s = new Session(SessionId('lone'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
})
describe('isToolPairingBalanced — region END (cut after a node)', () => {
it('is true for the last surface node of a closed step (the tool/result)', () => {
// After the tool/result the assistant's single call is answered → balanced.
const s = toolStepSession()
expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true)
})
it('is false for an assistant/message with a later tool/result in the same step', () => {
// After the assistant its tool-call is still unanswered → ending here strands
// the result.
const s = toolStepSession()
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
})
it('is true for a pre-step user/message', () => {
const s = toolStepSession()
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
it('is false at the tail when the node is inside an open (unclosed) step', () => {
// step/start then an assistant tool-call, but no tool/result yet (mid-flight).
// The after-tail cut still has one unanswered call → not balanced.
const s = new Session(SessionId('open-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
})
it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => {
// A steering message appended after step/end, at the tail. The prior step's
// pair is balanced and steering is neutral → the after-tail cut is balanced.
const s = new Session(SessionId('trailing-steer'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE)
expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true)
})
it('is true at the tail when no step ever opened', () => {
const s = new Session(SessionId('no-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
})
describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => {
// An assistant message with two tool-calls needs BOTH results before the cut
// after it is balanced — depth +2, then -1, -1.
function twoCallStep(): Session {
const s = new Session(SessionId('two-call'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' },
{ type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' },
],
}, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
it('is unbalanced after the first of two results (one call still open)', () => {
const s = twoCallStep()
expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false)
})
it('is balanced after the second result (both calls answered)', () => {
const s = twoCallStep()
expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true)
})
})
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
// A background task-done inject() lands a context/message INSIDE an open step,
// between the assistant (with a tool-call) and its tool/result. It is
// pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is
// still open across it) — it is NOT a free boundary in this position.
function midStepInjection(): Session {
const s = new Session(SessionId('mid-inject'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
it('start cut before the mid-step context/message is unbalanced (call still open)', () => {
const s = midStepInjection()
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false)
})
it('end cut after the mid-step context/message is unbalanced (call still open)', () => {
const s = midStepInjection()
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false)
})
})
describe('isToolPairingBalanced on an injection turn (no step)', () => {
// An idle inject() wraps a context/message in a bare turn/start →
// context/message → turn/end with NO step. The context node is a free boundary
// both ways (pairing-neutral, nothing open around it).
function injectionSession(): Session {
const s = new Session(SessionId('injection'))
s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } })
s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE)
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
it('start: balanced', () => {
const s = injectionSession()
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true)
})
it('end: balanced', () => {
const s = injectionSession()
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true)
})
})
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
// The case the log-position scan got wrong. After a compaction, a replacement
// user/message lands at a HIGH log seq but sits at the SURFACE head, beside
// the still-open step whose events follow it in the log. It carries no
// tool-call/result pair (just summarized prose), so it must be a balanced cut
// on BOTH sides regardless of its log neighbours.
function checkpointHeadedSession(): Session {
const s = new Session(SessionId('checkpoint'))
// A closed turn with a tool step → surface [u1, asst(call), result].
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE)
s.append('assistant/message', {
turn: 1, step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// An OPEN turn whose step is in progress (loop fires compaction here).
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 2, step: 1 })
// Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one
// summary user/message — appended now, so it carries a high log seq.
const u1 = seqOf(s, 'user/message')
const result = s.events.find(e => e.type === 'tool/result')!.seq
s.append('user/message', {
content: [{ type: 'text', text: 'CHECKPOINT' }],
source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: u1, end: result } })
// The step's own assistant/message lands AFTER the checkpoint in the log,
// still inside the open step.
s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
return s
}
it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => {
const s = checkpointHeadedSession()
const nodes = s.surface.nodes
const checkpointSeq = nodes[0]!.seq
// The checkpoint heads the surface, yet a surface node (the open step's
// assistant) follows it in LOG order — the exact split between surface
// position and log position that the log-position scan tripped on.
const laterSurfaceInLog = s.events.find(
e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq),
)
expect(laterSurfaceInLog).toBeDefined()
expect(nodes[0]!.seq).toBe(checkpointSeq)
})
it('start cut before the head checkpoint is balanced (it is the head)', () => {
const s = checkpointHeadedSession()
expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
})
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
// This is the exact assertion the log-position scan failed: the forward log
// scan from the checkpoint reached the open step's assistant/message and
// wrongly reported mid-step. The surface balance sees a neutral node whose
// following cut closes no open call.
const s = checkpointHeadedSession()
expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
})
})
describe('isToolPairingBalanced — corrupt surface guard', () => {
it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => {
// A surface that opens with a tool/result (no assistant call before it) is
// structurally corrupt — surfaced loudly rather than mis-classified.
const s = new Session(SessionId('corrupt'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE)
const { nodes, events } = surfaceOf(s)
expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/)
})
})