Merge master into codex/simp-session-log-representation
This commit is contained in:
@@ -9,7 +9,7 @@ This is the implementation tier of the compaction capability — see the [interf
|
||||
This backend owns the compaction policy:
|
||||
|
||||
- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts. Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
|
||||
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact'
|
||||
import { CompactService, renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
import { resolveConfig } from './types.ts'
|
||||
@@ -348,14 +347,14 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
// Both range edges must preserve assistant tool-call/result pairing.
|
||||
const events = session.events
|
||||
if (!isToolPairingBalanced(nodes, events, start)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const startSeq = nodes[startIdx]!
|
||||
if (!toolPairingBalancedBefore(session, startSeq)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
}
|
||||
// The cut after `end` is named by `end`'s surface successor, or `null` when
|
||||
// `end` is the tail.
|
||||
const afterEnd = nodes[endIdx + 1] ?? null
|
||||
if (!isToolPairingBalanced(nodes, events, afterEnd)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const endSeq = nodes[endIdx]!
|
||||
if (!toolPairingBalancedAfter(session, endSeq)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
@@ -508,7 +507,7 @@ export class BasicCompactService extends CompactService {
|
||||
// safe compactable prefix exists.
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!)) break
|
||||
if (toolPairingBalancedBefore(session, nodes[keepFromIdx]!)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -118,9 +118,9 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
for (const cp of checkpoints) {
|
||||
const index = nodes.indexOf(cp.seq)
|
||||
if (index === -1) continue // shadowed by a later checkpoint — no longer an edge.
|
||||
expect(isToolPairingBalanced(nodes, events, cp.seq),
|
||||
expect(toolPairingBalancedBefore(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region START`).toBe(true)
|
||||
expect(isToolPairingBalanced(nodes, events, nodes[index + 1] ?? null),
|
||||
expect(toolPairingBalancedAfter(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region END`).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
@@ -23,6 +23,12 @@ Both methods are **abstract** — the backend owns the entire strategy (token es
|
||||
|
||||
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
|
||||
|
||||
## Tool-pairing boundaries
|
||||
|
||||
The interface exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates that the event sequence is in the current surface and answers from balances cached per cut in surface order.
|
||||
|
||||
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-entry count. An unchanged generation extends the fold with unseen tail entries only; a log-only append with no new surface entry does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
|
||||
|
||||
## Surface contract
|
||||
|
||||
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { CompactionResult } from './types.ts'
|
||||
|
||||
export type { CompactionResult } from './types.ts'
|
||||
export { renderContentBlocks, renderTranscript } from './render.ts'
|
||||
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
|
||||
|
||||
/** Minimal agent context compaction needs without depending on the agent package. */
|
||||
export interface CompactAgentContext {
|
||||
@@ -67,6 +68,8 @@ export abstract class CompactService extends Service {
|
||||
* balanced so assistant tool calls remain paired with their results. A model-
|
||||
* backed implementation forwards cancellation and rejects active, missing,
|
||||
* reversed, or unbalanced ranges.
|
||||
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
|
||||
* for the edge checks.
|
||||
*
|
||||
* @param session - session to mutate.
|
||||
* @param start - first surface seq, inclusive.
|
||||
|
||||
131
packages/compact/compact/src/tool-pairing.ts
Normal file
131
packages/compact/compact/src/tool-pairing.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Tool-pairing balance over a session surface. Compaction changes surface
|
||||
* positions, so safe cuts are derived from tool-call/result content in current
|
||||
* surface order rather than step markers.
|
||||
* @module @deepseek-ai/dsh-compact/tool-pairing
|
||||
*/
|
||||
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Incremental balance state for one session surface generation. */
|
||||
interface BalanceCache {
|
||||
/** Surface rewrite generation this state describes. */
|
||||
generation: number
|
||||
/**
|
||||
* Balance of every surface cut in current order: a surface of N sequences has
|
||||
* N + 1 cuts, entry `i` being the cut before sequence `i` and the final entry
|
||||
* the cut after the surface tail.
|
||||
*/
|
||||
cutBalanced: readonly boolean[]
|
||||
/** Current surface position of each event seq, indexing {@link cutBalanced}. */
|
||||
indexBySeq: Map<number, number>
|
||||
/** In-progress tool-call count after the processed surface tail. */
|
||||
inProgressToolCalls: number
|
||||
}
|
||||
|
||||
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
|
||||
|
||||
/** Return how one surface event changes the in-progress tool-call count. */
|
||||
function eventDelta(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
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate the event named by a surface sequence. */
|
||||
function eventForSeq(events: readonly SessionEvent[], seq: number): SessionEvent {
|
||||
const event = events[seq]
|
||||
if (event === undefined || event.seq !== seq) {
|
||||
throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/** Fold surface sequences not yet in the cache into its balance state. */
|
||||
function extendCache(
|
||||
session: Session,
|
||||
cache: BalanceCache,
|
||||
seqs: readonly number[],
|
||||
): BalanceCache {
|
||||
const processed = cache.cutBalanced.length - 1
|
||||
const tail = seqs.slice(processed)
|
||||
// Validate the unseen tail before mutating the live cache, so a corrupt
|
||||
// append cannot leave a partially advanced state behind.
|
||||
const events = session.events
|
||||
const pendingCuts: boolean[] = []
|
||||
let inProgressToolCalls = cache.inProgressToolCalls
|
||||
for (const seq of tail) {
|
||||
inProgressToolCalls += eventDelta(eventForSeq(events, seq))
|
||||
if (inProgressToolCalls < 0) {
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
|
||||
}
|
||||
pendingCuts.push(inProgressToolCalls === 0)
|
||||
}
|
||||
|
||||
tail.forEach((seq, offset) => cache.indexBySeq.set(seq, processed + offset))
|
||||
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
|
||||
cache.inProgressToolCalls = inProgressToolCalls
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Return balance state synchronized with the current session surface. */
|
||||
function balanceCache(session: Session): BalanceCache {
|
||||
const surface = session.surface
|
||||
const seqs = surface.nodes
|
||||
const generation = surface.replaceGeneration
|
||||
const cached = balanceCacheBySession.get(session)
|
||||
|
||||
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > seqs.length) {
|
||||
// A rebuild is the same fold started from the empty-surface state, whose
|
||||
// single leading cut is trivially balanced.
|
||||
const rebuilt = extendCache(session, {
|
||||
generation,
|
||||
cutBalanced: [true],
|
||||
indexBySeq: new Map(),
|
||||
inProgressToolCalls: 0,
|
||||
}, seqs)
|
||||
balanceCacheBySession.set(session, rebuilt)
|
||||
return rebuilt
|
||||
}
|
||||
if (cached.cutBalanced.length - 1 < seqs.length) return extendCache(session, cached, seqs)
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Balance of the cut at a sequence's position plus offset, rejecting seqs outside current membership. */
|
||||
function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
|
||||
const index = cache.indexBySeq.get(seq)
|
||||
const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
|
||||
if (balanced === undefined) {
|
||||
throw new Error(`tool-pairing balance: surface seq ${seq} not found`)
|
||||
}
|
||||
return balanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately before a current surface sequence is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param seq - event sequence whose leading cut is checked.
|
||||
* @returns true when no unanswered tool call crosses the cut.
|
||||
* @throws when the seq is absent from the current surface, a surface sequence has no
|
||||
* matching log event, or a tool result has no preceding open call.
|
||||
*/
|
||||
export function toolPairingBalancedBefore(session: Session, seq: number): boolean {
|
||||
return cutBalance(balanceCache(session), seq, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately after a current surface sequence is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param seq - event sequence whose trailing cut is checked.
|
||||
* @returns true when no unanswered tool call crosses the cut.
|
||||
* @throws when the seq is absent from the current surface, a surface sequence has no
|
||||
* matching log event, or a tool result has no preceding open call.
|
||||
*/
|
||||
export function toolPairingBalancedAfter(session: Session, seq: number): boolean {
|
||||
return cutBalance(balanceCache(session), seq, 1)
|
||||
}
|
||||
320
packages/compact/compact/tests/tool-pairing.spec.ts
Normal file
320
packages/compact/compact/tests/tool-pairing.spec.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
|
||||
function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
|
||||
return session.events.filter(event => event.type === type)[nth]!.seq
|
||||
}
|
||||
|
||||
function surfaceSeq(session: Session, seq: number): number {
|
||||
const current = session.surface.nodes.find(candidate => candidate === seq)
|
||||
if (current === undefined) throw new Error(`seq ${seq} is not on the surface`)
|
||||
return current
|
||||
}
|
||||
|
||||
function before(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
return toolPairingBalancedBefore(session, surfaceSeq(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
return toolPairingBalancedAfter(session, surfaceSeq(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function closedToolStep(): Session {
|
||||
const session = new Session(SessionId('closed-tool-step'))
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
isError: false,
|
||||
}, SURFACE)
|
||||
return session
|
||||
}
|
||||
|
||||
describe('tool-pairing boundaries', () => {
|
||||
it('classifies closed and open single-call steps', () => {
|
||||
const closed = closedToolStep()
|
||||
expect(before(closed, 'user/message')).toBe(true)
|
||||
expect(after(closed, 'user/message')).toBe(true)
|
||||
expect(before(closed, 'assistant/message')).toBe(true)
|
||||
expect(after(closed, 'assistant/message')).toBe(false)
|
||||
expect(before(closed, 'tool/result')).toBe(false)
|
||||
expect(after(closed, 'tool/result')).toBe(true)
|
||||
|
||||
const open = new Session(SessionId('open-tool-step'))
|
||||
open.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
|
||||
})
|
||||
|
||||
it('requires every result from a multiple-call assistant message', () => {
|
||||
const session = new Session(SessionId('multiple-calls'))
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
|
||||
],
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c2'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
|
||||
expect(after(session, 'tool/result', 0)).toBe(false)
|
||||
expect(after(session, 'tool/result', 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
|
||||
const midStep = new Session(SessionId('neutral-mid-step'))
|
||||
midStep.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
midStep.append('context/message', {
|
||||
content: [{ type: 'text', text: 'background update' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, SURFACE)
|
||||
midStep.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(before(midStep, 'context/message')).toBe(false)
|
||||
expect(after(midStep, 'context/message')).toBe(false)
|
||||
|
||||
const free = new Session(SessionId('neutral-free'))
|
||||
free.append('context/message', {
|
||||
content: [{ type: 'text', text: 'idle injection' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(before(free, 'context/message')).toBe(true)
|
||||
expect(after(free, 'context/message')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pairing surface identity', () => {
|
||||
it('rebuilds after replace and rejects sequences removed from current membership', () => {
|
||||
const session = closedToolStep()
|
||||
const staleTail = surfaceSeq(session, seqOf(session, 'tool/result'))
|
||||
expect(toolPairingBalancedAfter(session, staleTail)).toBe(true)
|
||||
|
||||
const nodes = session.surface.nodes
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes.at(-1)! },
|
||||
sourceEventSeqs: [...nodes],
|
||||
})
|
||||
|
||||
const checkpoint = session.surface.nodes[0]!
|
||||
expect(toolPairingBalancedBefore(session, checkpoint)).toBe(true)
|
||||
expect(toolPairingBalancedAfter(session, checkpoint)).toBe(true)
|
||||
expect(() => toolPairingBalancedBefore(session, staleTail)).toThrow(/surface seq .* not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
|
||||
})
|
||||
|
||||
it('answers repeated queries from cached balances', () => {
|
||||
const session = closedToolStep()
|
||||
const assistant = surfaceSeq(session, seqOf(session, 'assistant/message'))
|
||||
expect(toolPairingBalancedAfter(session, assistant)).toBe(false)
|
||||
expect(toolPairingBalancedAfter(session, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing seqs before and after, including an empty surface', () => {
|
||||
const session = new Session(SessionId('missing-membership'))
|
||||
const missing = 999
|
||||
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'first node after empty cache' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pairing cache refresh', () => {
|
||||
it('does no event reads for unchanged or log-only growth, folds only appended nodes, and rebuilds on replace', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [{ type: 'text', text: 'user' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'assistant/message', seq: 1, time: 1,
|
||||
data: { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }] },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'tool/result', seq: 2, time: 2,
|
||||
data: { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: number[] = [0, 1, 2]
|
||||
let generation = 0
|
||||
let eventCollectionReads = 0
|
||||
let eventIndexReads = 0
|
||||
const trackedEvents = new Proxy(events, {
|
||||
get(target, property, receiver) {
|
||||
if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1
|
||||
return Reflect.get(target, property, receiver) as unknown
|
||||
},
|
||||
})
|
||||
const surface = {
|
||||
get nodes() { return nodes },
|
||||
get replaceGeneration() { return generation },
|
||||
}
|
||||
const session = {
|
||||
surface,
|
||||
get events() {
|
||||
eventCollectionReads += 1
|
||||
return trackedEvents
|
||||
},
|
||||
} as unknown as Session
|
||||
|
||||
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true)
|
||||
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
events.push({
|
||||
type: 'turn/end', seq: 3, time: 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
})
|
||||
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
events.push({
|
||||
type: 'user/message', seq: 4, time: 4,
|
||||
data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
nodes.push(4)
|
||||
expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(2)
|
||||
expect(eventIndexReads).toBe(4)
|
||||
|
||||
events.push(
|
||||
{
|
||||
type: 'assistant/message', seq: 5, time: 5,
|
||||
data: { turn: 2, step: 1, content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }] },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'tool/result', seq: 6, time: 6,
|
||||
data: { turn: 2, step: 1, callId: CallId('c2'), content: [], isError: false },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
)
|
||||
nodes.push(5, 6)
|
||||
expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(3)
|
||||
expect(eventIndexReads).toBe(6)
|
||||
|
||||
events.push({
|
||||
type: 'user/message', seq: 7, time: 7,
|
||||
data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } },
|
||||
surfaceOp: { op: 'replace', start: 0, end: 6 },
|
||||
})
|
||||
nodes.splice(0, nodes.length, 7)
|
||||
generation += 1
|
||||
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(4)
|
||||
expect(eventIndexReads).toBe(7)
|
||||
})
|
||||
|
||||
it('rebuilds defensively when a same-generation surface entry count regresses', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'user/message', seq: 1, time: 1,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: number[] = [0, 1]
|
||||
const session = {
|
||||
events,
|
||||
surface: { nodes, replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true)
|
||||
nodes.pop()
|
||||
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pairing corrupt surfaces', () => {
|
||||
it('throws for an orphan result during a rebuild', () => {
|
||||
const session = new Session(SessionId('orphan-rebuild'))
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
|
||||
it('retries an orphan result in an appended tail without committing partial cache state', () => {
|
||||
const session = new Session(SessionId('orphan-tail'))
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
|
||||
it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
|
||||
const missingSeq = 1
|
||||
const missing = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [missingSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/)
|
||||
|
||||
const mismatchedSeq = 0
|
||||
const mismatched = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 99, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [mismatchedSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user