Merge newest origin/master into token-meter-service
This commit is contained in:
@@ -39,7 +39,7 @@ export function selectCompactableRange(
|
||||
|
||||
const surfaceNodes = session.surface.nodes
|
||||
if (surfaceNodes.length !== pricedNodes.length
|
||||
|| surfaceNodes.some((node, index) => node.seq !== pricedNodes[index]?.seq)) {
|
||||
|| surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) {
|
||||
throw new Error('compaction: token-meter surface does not match the current session surface')
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function selectCompactableRange(
|
||||
const first = surfaceNodes[0]!
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const cutoff = surfaceNodes[keepFromIdx - 1]!
|
||||
return { start: first.seq, end: cutoff.seq }
|
||||
return { start: first, end: cutoff }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,8 +86,8 @@ export async function compactSurfaceRegion(
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
const nodes = session.surface.nodes
|
||||
const startIdx = nodes.findIndex(node => node.seq === start)
|
||||
const endIdx = nodes.findIndex(node => node.seq === end)
|
||||
const startIdx = nodes.indexOf(start)
|
||||
const endIdx = nodes.indexOf(end)
|
||||
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
|
||||
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
|
||||
if (startIdx > endIdx) {
|
||||
@@ -110,7 +110,7 @@ export async function compactSurfaceRegion(
|
||||
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
|
||||
}
|
||||
|
||||
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(node => node.seq)
|
||||
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
|
||||
const startEvent = session.append('compact/start', { turn: tail.turn })
|
||||
try {
|
||||
// Capture after the lock event so any later durable append, including a
|
||||
|
||||
@@ -359,8 +359,8 @@ describe('compaction region transaction', () => {
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
target,
|
||||
nodes[0]!.seq,
|
||||
nodes[1]!.seq,
|
||||
nodes[0]!,
|
||||
nodes[1]!,
|
||||
agent(owner),
|
||||
)).rejects.toThrow('compactRegion: agent.session must be the exact target session')
|
||||
|
||||
@@ -375,13 +375,13 @@ describe('compaction region transaction', () => {
|
||||
const before = session.surface.nodes
|
||||
const result = await compact.compactRegion(
|
||||
session,
|
||||
before[0]!.seq,
|
||||
before[3]!.seq,
|
||||
before[0]!,
|
||||
before[3]!,
|
||||
agent(session, MODEL),
|
||||
SIGNAL,
|
||||
)
|
||||
|
||||
expect(result.shadowedSeqs).toEqual(before.slice(0, 4).map(node => node.seq))
|
||||
expect(result.shadowedSeqs).toEqual(before.slice(0, 4))
|
||||
expect(result.shadowedTokenCount).toBeGreaterThan(0)
|
||||
expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
|
||||
expect(compact.calls[0]?.text).toContain('fixture user 1')
|
||||
@@ -411,8 +411,8 @@ describe('compaction region transaction', () => {
|
||||
const nodes = session.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
session,
|
||||
startOverride ?? nodes[0]!.seq,
|
||||
endOverride ?? nodes[1]!.seq,
|
||||
startOverride ?? nodes[0]!,
|
||||
endOverride ?? nodes[1]!,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toThrow(pattern)
|
||||
})
|
||||
@@ -423,8 +423,8 @@ describe('compaction region transaction', () => {
|
||||
const nodes = plain.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
plain,
|
||||
nodes[2]!.seq,
|
||||
nodes[1]!.seq,
|
||||
nodes[2]!,
|
||||
nodes[1]!,
|
||||
agent(plain, MODEL),
|
||||
)).rejects.toThrow(/is after end/)
|
||||
|
||||
@@ -432,14 +432,14 @@ describe('compaction region transaction', () => {
|
||||
const toolNodes = tools.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
tools,
|
||||
toolNodes[2]!.seq,
|
||||
toolNodes[4]!.seq,
|
||||
toolNodes[2]!,
|
||||
toolNodes[4]!,
|
||||
agent(tools, MODEL),
|
||||
)).rejects.toThrow(/start seq .* not a balanced boundary/)
|
||||
await expect(compact.compactRegion(
|
||||
tools,
|
||||
toolNodes[0]!.seq,
|
||||
toolNodes[1]!.seq,
|
||||
toolNodes[0]!,
|
||||
toolNodes[1]!,
|
||||
agent(tools, MODEL),
|
||||
)).rejects.toThrow(/end seq .* not a balanced boundary/)
|
||||
})
|
||||
@@ -451,8 +451,8 @@ describe('compaction region transaction', () => {
|
||||
const nodes = closed.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
closed,
|
||||
nodes[0]!.seq,
|
||||
nodes[1]!.seq,
|
||||
nodes[0]!,
|
||||
nodes[1]!,
|
||||
agent(closed, MODEL),
|
||||
)).rejects.toThrow(/no open turn/)
|
||||
|
||||
@@ -461,8 +461,8 @@ describe('compaction region transaction', () => {
|
||||
const lockedNodes = locked.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
locked,
|
||||
lockedNodes[0]!.seq,
|
||||
lockedNodes[1]!.seq,
|
||||
lockedNodes[0]!,
|
||||
lockedNodes[1]!,
|
||||
agent(locked, MODEL),
|
||||
)).rejects.toThrow(/already in progress/)
|
||||
})
|
||||
@@ -478,8 +478,8 @@ describe('compaction region transaction', () => {
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
session,
|
||||
node.seq,
|
||||
node.seq,
|
||||
node,
|
||||
node,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toThrow(/no open turn/)
|
||||
})
|
||||
@@ -498,8 +498,8 @@ describe('compaction region transaction', () => {
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
session,
|
||||
nodes[0]!.seq,
|
||||
nodes[2]!.seq,
|
||||
nodes[0]!,
|
||||
nodes[2]!,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toThrow(/selected surface changed/)
|
||||
})
|
||||
@@ -512,8 +512,8 @@ describe('compaction region transaction', () => {
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
session,
|
||||
before[0]!.seq,
|
||||
before[2]!.seq,
|
||||
before[0]!,
|
||||
before[2]!,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toThrow('summary unavailable')
|
||||
expect(session.surface.nodes).toEqual(before)
|
||||
@@ -528,8 +528,8 @@ describe('compaction region transaction', () => {
|
||||
const nodes = session.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
session,
|
||||
nodes[0]!.seq,
|
||||
nodes[2]!.seq,
|
||||
nodes[0]!,
|
||||
nodes[2]!,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toBe('plain failure')
|
||||
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
|
||||
@@ -549,8 +549,8 @@ describe('compaction region transaction', () => {
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
session,
|
||||
nodes[0]!.seq,
|
||||
nodes[2]!.seq,
|
||||
nodes[0]!,
|
||||
nodes[2]!,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toThrow(/session log changed/)
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
@@ -567,8 +567,8 @@ describe('compaction region transaction', () => {
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
session,
|
||||
nodes[0]!.seq,
|
||||
nodes[2]!.seq,
|
||||
nodes[0]!,
|
||||
nodes[2]!,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toThrow(/summary is not smaller/)
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
@@ -580,10 +580,10 @@ describe('compaction region transaction', () => {
|
||||
const nodes = session.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
session,
|
||||
nodes[0]!.seq,
|
||||
nodes[1]!.seq,
|
||||
nodes[0]!,
|
||||
nodes[1]!,
|
||||
agent(session),
|
||||
)).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
|
||||
)).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!, nodes[1]!] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -115,12 +115,12 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
// its start and end cuts are balanced in surface order.
|
||||
const nodes = agent.session.surface.nodes
|
||||
for (const cp of checkpoints) {
|
||||
const node = nodes.find(n => n.seq === cp.seq)
|
||||
if (!node) continue // shadowed by a later checkpoint — no longer an edge.
|
||||
expect(toolPairingBalancedBefore(agent.session, node),
|
||||
`checkpoint seq ${node.seq} must be a balanced region START`).toBe(true)
|
||||
expect(toolPairingBalancedAfter(agent.session, node),
|
||||
`checkpoint seq ${node.seq} must be a balanced region END`).toBe(true)
|
||||
const index = nodes.indexOf(cp.seq)
|
||||
if (index === -1) continue // shadowed by a later checkpoint — no longer an edge.
|
||||
expect(toolPairingBalancedBefore(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region START`).toBe(true)
|
||||
expect(toolPairingBalancedAfter(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region END`).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -25,9 +25,9 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev
|
||||
|
||||
## Tool-pairing boundaries
|
||||
|
||||
The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper identifies the node by seq alone and answers from balances cached per cut in current surface order, so a stale caller-held `node.next` cannot choose the cut.
|
||||
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-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
/**
|
||||
* 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 or linked-list fields supplied by a
|
||||
* caller.
|
||||
* surface order rather than step markers.
|
||||
* @module @deepseek-ai/dsh-compact/tool-pairing
|
||||
*/
|
||||
|
||||
import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
|
||||
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 nodes has
|
||||
* N + 1 cuts, entry `i` being the cut before node `i` and the final entry
|
||||
* 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 node seq, indexing {@link cutBalanced}. */
|
||||
/** 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
|
||||
@@ -27,7 +26,7 @@ interface BalanceCache {
|
||||
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
|
||||
|
||||
/** Return how one surface event changes the in-progress tool-call count. */
|
||||
function nodeDelta(event: SessionEvent): number {
|
||||
function eventDelta(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'assistant/message':
|
||||
return event.data.content.filter(block => block.type === 'tool-call').length
|
||||
@@ -38,37 +37,37 @@ function nodeDelta(event: SessionEvent): number {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate the event named by a surface node. */
|
||||
function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): SessionEvent {
|
||||
const event = events[node.seq]
|
||||
if (event === undefined || event.seq !== node.seq) {
|
||||
throw new Error(`tool-pairing balance: surface seq ${node.seq} has no matching session event (corrupt surface)`)
|
||||
/** 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 nodes not yet in the cache into its balance state. */
|
||||
/** Fold surface sequences not yet in the cache into its balance state. */
|
||||
function extendCache(
|
||||
session: Session,
|
||||
cache: BalanceCache,
|
||||
nodes: readonly SurfaceNode[],
|
||||
seqs: readonly number[],
|
||||
): BalanceCache {
|
||||
const processed = cache.cutBalanced.length - 1
|
||||
const tail = nodes.slice(processed)
|
||||
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 node of tail) {
|
||||
inProgressToolCalls += nodeDelta(eventForNode(events, node))
|
||||
for (const seq of tail) {
|
||||
inProgressToolCalls += eventDelta(eventForSeq(events, seq))
|
||||
if (inProgressToolCalls < 0) {
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
|
||||
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((node, offset) => cache.indexBySeq.set(node.seq, processed + offset))
|
||||
tail.forEach((seq, offset) => cache.indexBySeq.set(seq, processed + offset))
|
||||
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
|
||||
cache.inProgressToolCalls = inProgressToolCalls
|
||||
return cache
|
||||
@@ -77,11 +76,11 @@ function extendCache(
|
||||
/** Return balance state synchronized with the current session surface. */
|
||||
function balanceCache(session: Session): BalanceCache {
|
||||
const surface = session.surface
|
||||
const nodes = surface.nodes
|
||||
const seqs = surface.nodes
|
||||
const generation = surface.replaceGeneration
|
||||
const cached = balanceCacheBySession.get(session)
|
||||
|
||||
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > nodes.length) {
|
||||
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, {
|
||||
@@ -89,15 +88,15 @@ function balanceCache(session: Session): BalanceCache {
|
||||
cutBalanced: [true],
|
||||
indexBySeq: new Map(),
|
||||
inProgressToolCalls: 0,
|
||||
}, nodes)
|
||||
}, seqs)
|
||||
balanceCacheBySession.set(session, rebuilt)
|
||||
return rebuilt
|
||||
}
|
||||
if (cached.cutBalanced.length - 1 < nodes.length) return extendCache(session, cached, nodes)
|
||||
if (cached.cutBalanced.length - 1 < seqs.length) return extendCache(session, cached, seqs)
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Balance of the cut at a node's position plus offset, rejecting seqs outside current membership. */
|
||||
/** 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]
|
||||
@@ -108,25 +107,25 @@ function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately before a current surface node is tool-pairing balanced.
|
||||
* Whether the cut immediately before a current surface sequence is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param node - surface node whose leading cut is checked; only its seq identifies it.
|
||||
* @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 node has no
|
||||
* @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, node: SurfaceNode): boolean {
|
||||
return cutBalance(balanceCache(session), node.seq, 0)
|
||||
export function toolPairingBalancedBefore(session: Session, seq: number): boolean {
|
||||
return cutBalance(balanceCache(session), seq, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately after a current surface node is tool-pairing balanced.
|
||||
* Whether the cut immediately after a current surface sequence is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param node - surface node whose trailing cut is checked; only its seq identifies it.
|
||||
* @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 node has no
|
||||
* @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, node: SurfaceNode): boolean {
|
||||
return cutBalance(balanceCache(session), node.seq, 1)
|
||||
export function toolPairingBalancedAfter(session: Session, seq: number): boolean {
|
||||
return cutBalance(balanceCache(session), seq, 1)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, SurfaceNode } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
|
||||
@@ -10,18 +10,18 @@ function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
|
||||
return session.events.filter(event => event.type === type)[nth]!.seq
|
||||
}
|
||||
|
||||
function nodeAt(session: Session, seq: number): SurfaceNode {
|
||||
const node = session.surface.nodes.find(candidate => candidate.seq === seq)
|
||||
if (node === undefined) throw new Error(`seq ${seq} is not a surface node`)
|
||||
return node
|
||||
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, nodeAt(session, seqOf(session, type, nth)))
|
||||
return toolPairingBalancedBefore(session, surfaceSeq(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
return toolPairingBalancedAfter(session, nodeAt(session, seqOf(session, type, nth)))
|
||||
return toolPairingBalancedAfter(session, surfaceSeq(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function closedToolStep(): Session {
|
||||
@@ -117,9 +117,9 @@ describe('tool-pairing boundaries', () => {
|
||||
})
|
||||
|
||||
describe('tool-pairing surface identity', () => {
|
||||
it('rebuilds after replace and rejects nodes removed from current membership', () => {
|
||||
it('rebuilds after replace and rejects sequences removed from current membership', () => {
|
||||
const session = closedToolStep()
|
||||
const staleTail = nodeAt(session, seqOf(session, 'tool/result'))
|
||||
const staleTail = surfaceSeq(session, seqOf(session, 'tool/result'))
|
||||
expect(toolPairingBalancedAfter(session, staleTail)).toBe(true)
|
||||
|
||||
const nodes = session.surface.nodes
|
||||
@@ -127,8 +127,8 @@ describe('tool-pairing surface identity', () => {
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes.at(-1)!.seq },
|
||||
sourceEventSeqs: nodes.map(node => node.seq),
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes.at(-1)! },
|
||||
sourceEventSeqs: [...nodes],
|
||||
})
|
||||
|
||||
const checkpoint = session.surface.nodes[0]!
|
||||
@@ -138,16 +138,16 @@ describe('tool-pairing surface identity', () => {
|
||||
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
|
||||
})
|
||||
|
||||
it('ignores a caller-held node next field and answers from cached balances', () => {
|
||||
it('answers repeated queries from cached balances', () => {
|
||||
const session = closedToolStep()
|
||||
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
|
||||
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)
|
||||
expect(toolPairingBalancedAfter(session, { ...assistant, next: 999 })).toBe(false)
|
||||
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: SurfaceNode = { seq: 999, prev: null, next: null }
|
||||
const missing = 999
|
||||
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
|
||||
@@ -183,11 +183,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: SurfaceNode[] = [
|
||||
{ seq: 0, prev: null, next: 1 },
|
||||
{ seq: 1, prev: 0, next: 2 },
|
||||
{ seq: 2, prev: 1, next: null },
|
||||
]
|
||||
const nodes: number[] = [0, 1, 2]
|
||||
let generation = 0
|
||||
let eventCollectionReads = 0
|
||||
let eventIndexReads = 0
|
||||
@@ -231,7 +227,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
nodes.push({ seq: 4, prev: 2, next: null })
|
||||
nodes.push(4)
|
||||
expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(2)
|
||||
expect(eventIndexReads).toBe(4)
|
||||
@@ -253,10 +249,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
)
|
||||
nodes.push(
|
||||
{ seq: 5, prev: 4, next: 6 },
|
||||
{ seq: 6, prev: 5, next: null },
|
||||
)
|
||||
nodes.push(5, 6)
|
||||
expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(3)
|
||||
expect(eventIndexReads).toBe(6)
|
||||
@@ -266,14 +259,14 @@ describe('tool-pairing cache refresh', () => {
|
||||
data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } },
|
||||
surfaceOp: { op: 'replace', start: 0, end: 6 },
|
||||
})
|
||||
nodes.splice(0, nodes.length, { seq: 7, prev: null, next: null })
|
||||
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 node count regresses', () => {
|
||||
it('rebuilds defensively when a same-generation surface entry count regresses', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
@@ -284,10 +277,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: SurfaceNode[] = [
|
||||
{ seq: 0, prev: null, next: 1 },
|
||||
{ seq: 1, prev: 0, next: null },
|
||||
]
|
||||
const nodes: number[] = [0, 1]
|
||||
const session = {
|
||||
events,
|
||||
surface: { nodes, replaceGeneration: 0 },
|
||||
@@ -321,24 +311,24 @@ describe('tool-pairing corrupt surfaces', () => {
|
||||
})
|
||||
|
||||
it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
|
||||
const missingNode: SurfaceNode = { seq: 1, prev: null, next: null }
|
||||
const missingSeq = 1
|
||||
const missing = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [missingNode], replaceGeneration: 0 },
|
||||
surface: { nodes: [missingSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(missing, missingNode)).toThrow(/no matching session event/)
|
||||
expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/)
|
||||
|
||||
const mismatchedNode: SurfaceNode = { seq: 0, prev: null, next: null }
|
||||
const mismatchedSeq = 0
|
||||
const mismatched = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 99, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [mismatchedNode], replaceGeneration: 0 },
|
||||
surface: { nodes: [mismatchedSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(mismatched, mismatchedNode)).toThrow(/no matching session event/)
|
||||
expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user