Merge master into tool-result pruning
Retarget the pruning feature onto the merged compaction foundation. Adapt content-only tool-result rewrite validation to the session-owned surface manager, migrate the demo wiring to repl-agent, and refresh generated type, catalog, and website contracts while preserving pressure and overflow pruning behavior.
This commit is contained in:
@@ -12,13 +12,13 @@ This backend owns the compaction policy:
|
||||
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
|
||||
- **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. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain 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.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, 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.
|
||||
- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||
- **Overflow recovery** — below-threshold overflow bypasses normal retention and first prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
|
||||
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||
- **Overflow recovery** — below-threshold overflow bypasses normal retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
|
||||
|
||||
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||
|
||||
## Config (`BasicCompactConfig`)
|
||||
|
||||
@@ -28,7 +28,8 @@ Every setting is optional. The pressure and retention policy applies to the toke
|
||||
|---|---|---|
|
||||
| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. |
|
||||
| `retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. |
|
||||
| `summarizationModel` | no (default `''`) | Empty resolves the latest logged routed model, then `AgentOptions.model`. |
|
||||
| `summarizationProvider` | no (default `''`) | Set together with `summarizationModel`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
|
||||
| `summarizationModel` | no (default `''`) | Set together with `summarizationProvider`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
|
||||
| `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
|
||||
| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. |
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -18,6 +18,7 @@ const DEFAULT_RETAIN_RATIO = 0.16
|
||||
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'thresholdRatio',
|
||||
'retainTokens',
|
||||
'summarizationProvider',
|
||||
'summarizationModel',
|
||||
'maxTokens',
|
||||
'compactionRetries',
|
||||
@@ -31,8 +32,8 @@ function validateConfigKeys(config: BasicCompactConfig): void {
|
||||
if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: unknown key "${key}" `
|
||||
+ '(allowed: thresholdRatio, retainTokens, summarizationModel, maxTokens, '
|
||||
+ 'compactionRetries, maxOverflowRetries, auto)',
|
||||
+ '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, '
|
||||
+ 'maxTokens, compactionRetries, maxOverflowRetries, auto)',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +56,7 @@ export function resolveConfig(
|
||||
const resolved: ResolvedConfig = {
|
||||
thresholdRatio,
|
||||
retainTokens,
|
||||
summarizationProvider: config.summarizationProvider ?? '',
|
||||
summarizationModel: config.summarizationModel ?? '',
|
||||
maxTokens: config.maxTokens ?? 8192,
|
||||
compactionRetries: config.compactionRetries ?? 1,
|
||||
@@ -73,9 +75,17 @@ export function resolveConfig(
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries)
|
||||
if (typeof resolved.summarizationProvider !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationProvider must be a string')
|
||||
}
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string')
|
||||
}
|
||||
if ((resolved.summarizationProvider.length === 0) !== (resolved.summarizationModel.length === 0)) {
|
||||
throw new Error(
|
||||
'BasicCompactConfig: summarizationProvider and summarizationModel must both be set or both be empty',
|
||||
)
|
||||
}
|
||||
if (typeof resolved.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean')
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import z from 'schemastery'
|
||||
import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
// Type-only: makes the optional sibling service available to `ctx.get()`.
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
ResolvedConfig,
|
||||
} from './types.ts'
|
||||
|
||||
export { resolveConfig } from './config.ts'
|
||||
export type {
|
||||
BasicCompactConfig,
|
||||
ResolvedConfig,
|
||||
@@ -48,6 +47,7 @@ export class BasicCompactService extends CompactService {
|
||||
static Config: z<BasicCompactConfig> = z.object({
|
||||
thresholdRatio: z.number().default(0.8),
|
||||
retainTokens: z.number().step(1),
|
||||
summarizationProvider: z.string().default(''),
|
||||
summarizationModel: z.string().default(''),
|
||||
maxTokens: z.number().step(1).min(1).default(8192),
|
||||
compactionRetries: z.number().step(1).min(0).default(1),
|
||||
@@ -141,11 +141,11 @@ export class BasicCompactService extends CompactService {
|
||||
* @param signal - optional cancellation forwarded to the adapter.
|
||||
* @returns safe text summary blocks and exact auxiliary-call provenance.
|
||||
*/
|
||||
async summarize(
|
||||
protected async summarize(
|
||||
text: string,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
return summarizeWithLlm(this.ctx, this.config, text, agent, signal)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,16 @@ export class BasicCompactService extends CompactService {
|
||||
const meter = this.ctx.tokenMeter
|
||||
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
|
||||
let measurement = meter.measure(agent.session)
|
||||
if (trigger === 'pressure' && measurement.totalTokens < threshold) return null
|
||||
switch (trigger) {
|
||||
case 'context-overflow':
|
||||
break
|
||||
case 'pressure':
|
||||
if (measurement.totalTokens < threshold) return null
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(trigger, 'compaction trigger')
|
||||
}
|
||||
|
||||
// Pruning is optional so compact-basic remains independently composable.
|
||||
// Once either trigger qualifies, land the model-free pass before choosing
|
||||
@@ -183,7 +192,7 @@ export class BasicCompactService extends CompactService {
|
||||
if (trigger === 'context-overflow') {
|
||||
const range = selectCompactableRange(agent.session, measurement, 0)
|
||||
if (range === null) return null
|
||||
return this.compactRegion(agent.session, range.start, range.end, agent, signal)
|
||||
return this.compactRegion(range.start, range.end, agent, signal)
|
||||
}
|
||||
|
||||
if (measurement.totalTokens < threshold) return null
|
||||
@@ -197,7 +206,7 @@ export class BasicCompactService extends CompactService {
|
||||
/* v8 ignore next -- paired with the defensive post-success branch above. */
|
||||
break
|
||||
}
|
||||
result = await this.compactRegion(agent.session, range.start, range.end, agent, signal)
|
||||
result = await this.compactRegion(range.start, range.end, agent, signal)
|
||||
measurement = meter.measure(agent.session)
|
||||
if (measurement.totalTokens < threshold) return result
|
||||
}
|
||||
@@ -209,10 +218,8 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact one inclusive positional surface range using the effective
|
||||
* token meter for all retention and shrink pricing. Reject an agent that does
|
||||
* not own the exact target before any mutation.
|
||||
* @param session - session whose surface is mutated; must equal `agent.session`.
|
||||
* Compact one inclusive positional range from the agent-owned surface using
|
||||
* the effective token meter for all retention and shrink pricing.
|
||||
* @param start - inclusive first surface-node seq.
|
||||
* @param end - inclusive last surface-node seq.
|
||||
* @param agent - owner of the target session, used by the summarizer.
|
||||
@@ -220,15 +227,12 @@ export class BasicCompactService extends CompactService {
|
||||
* @returns the successful durable compaction result.
|
||||
*/
|
||||
override async compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
if (session !== agent.session) {
|
||||
throw new Error('compactRegion: agent.session must be the exact target session')
|
||||
}
|
||||
const session = agent.session
|
||||
return compactSurfaceRegion({
|
||||
meter: this.ctx.tokenMeter,
|
||||
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
|
||||
|
||||
@@ -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
|
||||
@@ -123,7 +123,7 @@ export async function compactSurfaceRegion(
|
||||
}
|
||||
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
|
||||
const text = renderTranscript(session.events, shadowedSeqs)
|
||||
const { summary, model, maxTokens } = await dependencies.summarize(text, agent, signal)
|
||||
const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal)
|
||||
|
||||
const currentMeasurement = dependencies.meter.measure(session)
|
||||
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
|
||||
@@ -145,6 +145,7 @@ export async function compactSurfaceRegion(
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
provider,
|
||||
model,
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
})
|
||||
|
||||
@@ -58,6 +58,7 @@ const CHECKPOINT_PREAMBLE =
|
||||
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
|
||||
export interface SummaryResult {
|
||||
summary: ContentBlock[]
|
||||
provider: string
|
||||
model: string
|
||||
maxTokens?: number
|
||||
}
|
||||
@@ -78,17 +79,27 @@ export async function summarizeWithLlm(
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SummaryResult> {
|
||||
const latestModel = agent.session.requestHeader()?.config.model
|
||||
const model = config.summarizationModel || latestModel || agent.options.model || ''
|
||||
if (model.length === 0) {
|
||||
const latest = agent.session.requestHeader()?.config
|
||||
const configured = config.summarizationProvider.length === 0
|
||||
? undefined
|
||||
: { provider: config.summarizationProvider, model: config.summarizationModel }
|
||||
const agentTarget = agent.options.provider !== undefined
|
||||
&& agent.options.provider.length > 0
|
||||
&& agent.options.model !== undefined
|
||||
&& agent.options.model.length > 0
|
||||
? { provider: agent.options.provider, model: agent.options.model }
|
||||
: undefined
|
||||
const target = configured ?? latest ?? agentTarget
|
||||
if (target === undefined) {
|
||||
throw new Error(
|
||||
'no model available for summarization: set BasicCompactConfig.summarizationModel, route one request, or set AgentOptions.model',
|
||||
'no provider/model available for summarization: set both BasicCompactConfig summarization fields, route one request, or set both AgentOptions fields',
|
||||
)
|
||||
}
|
||||
|
||||
const assembler = new BlockAssembler()
|
||||
const options: GenerateOptions = {
|
||||
model,
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
|
||||
@@ -106,7 +117,12 @@ export async function summarizeWithLlm(
|
||||
if (!summary.some(block => block.text.trim().length > 0)) {
|
||||
throw new Error('summarization produced no text summary content')
|
||||
}
|
||||
return { summary, model: options.model, maxTokens: config.maxTokens }
|
||||
return {
|
||||
summary,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
maxTokens: config.maxTokens,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,9 @@ export interface BasicCompactConfig {
|
||||
thresholdRatio?: number
|
||||
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
|
||||
retainTokens?: number
|
||||
/** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */
|
||||
/** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */
|
||||
summarizationProvider?: string
|
||||
/** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */
|
||||
summarizationModel?: string
|
||||
/** Provider generation cap for summarization. Defaults to `8192`. */
|
||||
maxTokens?: number
|
||||
@@ -26,6 +28,7 @@ export interface BasicCompactConfig {
|
||||
export interface ResolvedConfig {
|
||||
readonly thresholdRatio: number
|
||||
readonly retainTokens: number
|
||||
readonly summarizationProvider: string
|
||||
readonly summarizationModel: string
|
||||
readonly maxTokens: number
|
||||
readonly compactionRetries: number
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import BasicCompactService, { resolveConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
@@ -22,7 +23,7 @@ function createContext(contextWindow = 1_000): Context {
|
||||
}
|
||||
|
||||
function agent(session: Session, model?: string): Agent {
|
||||
return { session, options: model === undefined ? {} : { model } } as Agent
|
||||
return { session, options: model === undefined ? {} : { provider: model, model } } as Agent
|
||||
}
|
||||
|
||||
/** Closed two-message turns followed by one open turn for durable compaction events. */
|
||||
@@ -37,11 +38,12 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
if (turn === 1) {
|
||||
session.append('request/header', {
|
||||
header: { config: { model: MODEL } },
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
}
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: `${text} assistant ${turn}` }],
|
||||
@@ -68,11 +70,12 @@ function toolConversation(): Session {
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
if (turn === 1) {
|
||||
session.append('request/header', {
|
||||
header: { config: { model: MODEL } },
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
}
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [
|
||||
@@ -108,13 +111,14 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
|
||||
}
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', {
|
||||
header: { config: { model: MODEL } },
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
@@ -133,6 +137,7 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
|
||||
|
||||
class TestCompactService extends BasicCompactService {
|
||||
summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
|
||||
summaryProvider = 'summary-provider'
|
||||
summaryModel = 'summary-model'
|
||||
error: unknown
|
||||
mutateDuringSummary: (() => void) | undefined
|
||||
@@ -142,11 +147,16 @@ class TestCompactService extends BasicCompactService {
|
||||
text: string,
|
||||
_agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
this.calls.push({ text, signal })
|
||||
this.mutateDuringSummary?.()
|
||||
if (this.error !== undefined) throw this.error
|
||||
return { summary: this.summary, model: this.summaryModel, maxTokens: 123 }
|
||||
return {
|
||||
summary: this.summary,
|
||||
provider: this.summaryProvider,
|
||||
model: this.summaryModel,
|
||||
maxTokens: 123,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +184,7 @@ describe('compact configuration and defaults', () => {
|
||||
expect(resolved).toEqual({
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 160,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
@@ -209,7 +220,10 @@ describe('compact configuration and defaults', () => {
|
||||
[{ compactionRetries: -1 }, /compactionRetries/],
|
||||
[{ maxOverflowRetries: -1 }, /maxOverflowRetries/],
|
||||
[{ auto: 'yes' }, /auto must be a boolean/],
|
||||
[{ summarizationProvider: 1 }, /summarizationProvider must be a string/],
|
||||
[{ summarizationModel: 1 }, /summarizationModel must be a string/],
|
||||
[{ summarizationProvider: MODEL }, /must both be set or both be empty/],
|
||||
[{ summarizationModel: MODEL }, /must both be set or both be empty/],
|
||||
[{ thresholdRatio: 0 }, /number in \(0, 1\]/],
|
||||
[{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
|
||||
[{ retainTokens: -1 }, /non-negative integer/],
|
||||
@@ -244,7 +258,7 @@ describe('pressure measurement and retention', () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = conversation()
|
||||
session.append('request/header', {
|
||||
header: { config: { model: 'unlisted-model' } },
|
||||
header: { config: { provider: 'unlisted-provider', model: 'unlisted-model' } },
|
||||
reason: 'resume',
|
||||
})
|
||||
await expect(compactIfNeeded(compact, session))
|
||||
@@ -258,10 +272,11 @@ describe('pressure measurement and retention', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', {
|
||||
header: { config: { model: MODEL } },
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
|
||||
@@ -305,7 +320,7 @@ describe('pressure measurement and retention', () => {
|
||||
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }]
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { model: MODEL },
|
||||
config: { provider: MODEL, model: MODEL },
|
||||
system: 's'.repeat(600),
|
||||
messagePrefix: prefix,
|
||||
},
|
||||
@@ -326,7 +341,7 @@ describe('pressure measurement and retention', () => {
|
||||
}, ctx)
|
||||
const session = conversation(4)
|
||||
session.append('request/header', {
|
||||
header: { config: { model: 'actual' } },
|
||||
header: { config: { provider: 'actual', model: 'actual' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
|
||||
@@ -342,14 +357,14 @@ describe('pressure measurement and retention', () => {
|
||||
const empty = new Session(SessionId('empty'))
|
||||
empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
empty.append('request/header', {
|
||||
header: { config: { model: MODEL }, system: 'x'.repeat(100_000) },
|
||||
header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
|
||||
reason: 'initial',
|
||||
})
|
||||
expect(await compactIfNeeded(compact, empty)).toBeNull()
|
||||
|
||||
const retained = conversation(1)
|
||||
retained.append('request/header', {
|
||||
header: { config: { model: MODEL }, system: 'x'.repeat(100_000) },
|
||||
header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
|
||||
reason: 'resume',
|
||||
})
|
||||
expect(await compactIfNeeded(compact, retained)).toBeNull()
|
||||
@@ -419,6 +434,7 @@ describe('pressure measurement and retention', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
|
||||
@@ -511,39 +527,18 @@ describe('optional model-free tool-result pruning', () => {
|
||||
})
|
||||
|
||||
describe('compaction region transaction', () => {
|
||||
it('rejects an agent that does not own the exact target session before mutation', async () => {
|
||||
const compact = service()
|
||||
const target = conversation(2)
|
||||
const owner = conversation(1)
|
||||
const targetEvents = [...target.events]
|
||||
const ownerEvents = [...owner.events]
|
||||
const nodes = target.surface.nodes
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
target,
|
||||
nodes[0]!.seq,
|
||||
nodes[1]!.seq,
|
||||
agent(owner),
|
||||
)).rejects.toThrow('compactRegion: agent.session must be the exact target session')
|
||||
|
||||
expect(target.events).toEqual(targetEvents)
|
||||
expect(owner.events).toEqual(ownerEvents)
|
||||
expect(compact.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
|
||||
const compact = service()
|
||||
const session = conversation(3)
|
||||
const before = session.surface.nodes
|
||||
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')
|
||||
@@ -551,6 +546,7 @@ describe('compaction region transaction', () => {
|
||||
expect(summary?.data).toMatchObject({
|
||||
shadowedSeqs: result.shadowedSeqs,
|
||||
shadowedTokenCount: result.shadowedTokenCount,
|
||||
provider: 'summary-provider',
|
||||
model: 'summary-model',
|
||||
maxTokens: 123,
|
||||
})
|
||||
@@ -571,9 +567,8 @@ describe('compaction region transaction', () => {
|
||||
const session = conversation(2)
|
||||
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)
|
||||
})
|
||||
@@ -583,24 +578,21 @@ describe('compaction region transaction', () => {
|
||||
const plain = conversation(2)
|
||||
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/)
|
||||
|
||||
const tools = toolConversation()
|
||||
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/)
|
||||
})
|
||||
@@ -611,9 +603,8 @@ describe('compaction region transaction', () => {
|
||||
closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
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/)
|
||||
|
||||
@@ -621,9 +612,8 @@ describe('compaction region transaction', () => {
|
||||
locked.append('compact/start', { turn: 2 })
|
||||
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/)
|
||||
})
|
||||
@@ -638,9 +628,8 @@ describe('compaction region transaction', () => {
|
||||
const node = session.surface.nodes[0]!
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
session,
|
||||
node.seq,
|
||||
node.seq,
|
||||
node,
|
||||
node,
|
||||
agent(session, MODEL),
|
||||
)).rejects.toThrow(/no open turn/)
|
||||
})
|
||||
@@ -658,9 +647,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.toThrow(/selected surface changed/)
|
||||
})
|
||||
@@ -672,9 +660,8 @@ describe('compaction region transaction', () => {
|
||||
const before = session.surface.nodes
|
||||
|
||||
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)
|
||||
@@ -688,9 +675,8 @@ describe('compaction region transaction', () => {
|
||||
const session = conversation(2)
|
||||
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)
|
||||
@@ -702,16 +688,15 @@ describe('compaction region transaction', () => {
|
||||
const session = conversation(2)
|
||||
compact.mutateDuringSummary = () => {
|
||||
session.append('request/header', {
|
||||
header: { config: { model: MODEL } },
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
}
|
||||
const nodes = session.surface.nodes
|
||||
|
||||
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)
|
||||
@@ -727,9 +712,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.toThrow(/summary is not smaller/)
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
@@ -745,6 +729,7 @@ describe('compaction region transaction', () => {
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: 'historical', model: 'historical' },
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'answer '.repeat(100) }],
|
||||
@@ -752,11 +737,10 @@ describe('compaction region transaction', () => {
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
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]!] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -786,18 +770,28 @@ class ScriptedAdapter extends LlmAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
class ExposedCompactService extends BasicCompactService {
|
||||
runSummarize(
|
||||
text: string,
|
||||
owner: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
return this.summarize(text, owner, signal)
|
||||
}
|
||||
}
|
||||
|
||||
async function summarizerHarness(
|
||||
blocks: readonly ContentBlock[],
|
||||
finish?: (StreamChunk & { type: 'finish' })['reason'],
|
||||
model = MODEL,
|
||||
config: BasicCompactConfig = { auto: false },
|
||||
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
|
||||
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
void new TokenMeterService(ctx, { contextWindow: 1_000 })
|
||||
const adapter = new ScriptedAdapter(blocks, finish)
|
||||
ctx.llm.registerAdapter([model], adapter)
|
||||
const compact = new BasicCompactService(ctx, config)
|
||||
const compact = new ExposedCompactService(ctx, config)
|
||||
return { ctx, adapter, compact }
|
||||
}
|
||||
|
||||
@@ -809,18 +803,21 @@ describe('default one-shot summarizer', () => {
|
||||
{ type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' },
|
||||
], undefined, MODEL, {
|
||||
auto: false,
|
||||
summarizationProvider: MODEL,
|
||||
summarizationModel: MODEL,
|
||||
maxTokens: 321,
|
||||
})
|
||||
const session = conversation(1)
|
||||
const output = await compact.summarize('transcript', agent(session, 'fallback'), SIGNAL)
|
||||
const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL)
|
||||
|
||||
expect(output).toEqual({
|
||||
summary: [{ type: 'text', text: 'public summary' }],
|
||||
provider: MODEL,
|
||||
model: MODEL,
|
||||
maxTokens: 321,
|
||||
})
|
||||
expect(adapter.lastOptions).toMatchObject({
|
||||
provider: MODEL,
|
||||
model: MODEL,
|
||||
maxTokens: 321,
|
||||
signal: SIGNAL,
|
||||
@@ -829,44 +826,49 @@ describe('default one-shot summarizer', () => {
|
||||
expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent')
|
||||
})
|
||||
|
||||
it('resolves latest routed model before AgentOptions.model', async () => {
|
||||
it('resolves the latest routed provider/model before the AgentOptions pair', async () => {
|
||||
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }], undefined, 'routed')
|
||||
const session = conversation(1)
|
||||
session.append('request/header', {
|
||||
header: { config: { model: 'routed' } },
|
||||
header: { config: { provider: 'routed', model: 'routed' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
const output = await compact.summarize('history', agent(session, 'fallback'))
|
||||
const output = await compact.runSummarize('history', agent(session, 'fallback'))
|
||||
expect(output.provider).toBe('routed')
|
||||
expect(output.model).toBe('routed')
|
||||
expect(adapter.lastOptions?.provider).toBe('routed')
|
||||
expect(adapter.lastOptions?.model).toBe('routed')
|
||||
})
|
||||
|
||||
it('records the model actually dispatched after one-shot stream routing', async () => {
|
||||
const { ctx, compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
|
||||
const routedAdapter = new ScriptedAdapter([{ type: 'text', text: 'routed summary' }])
|
||||
ctx.llm.registerAdapter(['routed-summary-model'], routedAdapter)
|
||||
ctx.llm.registerAdapter(['routed-summary-provider'], routedAdapter)
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
options.provider = 'routed-summary-provider'
|
||||
options.model = 'routed-summary-model'
|
||||
return next()
|
||||
})
|
||||
|
||||
const session = conversation(3, 'large history '.repeat(500))
|
||||
const nodes = session.surface.nodes
|
||||
await compact.compactRegion(session, nodes[0]!.seq, nodes[3]!.seq, agent(session, MODEL), SIGNAL)
|
||||
await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL)
|
||||
expect(session.events.findLast(event => event.type === 'compact/summary')?.data).toMatchObject({
|
||||
summary: [{ type: 'text', text: 'routed summary' }],
|
||||
provider: 'routed-summary-provider',
|
||||
model: 'routed-summary-model',
|
||||
})
|
||||
expect(routedAdapter.lastOptions?.provider).toBe('routed-summary-provider')
|
||||
expect(routedAdapter.lastOptions?.model).toBe('routed-summary-model')
|
||||
})
|
||||
|
||||
it('fails clearly when no summarization model can be resolved', async () => {
|
||||
it('fails clearly when no complete summarization target can be resolved', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
void new TokenMeterService(ctx)
|
||||
const compact = new BasicCompactService(ctx, { auto: false })
|
||||
await expect(compact.summarize('history', agent(new Session(SessionId('model-less')))))
|
||||
.rejects.toThrow(/no model available for summarization/)
|
||||
const compact = new ExposedCompactService(ctx, { auto: false })
|
||||
await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less')))))
|
||||
.rejects.toThrow(/no provider\/model available for summarization/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -880,7 +882,7 @@ describe('default one-shot summarizer', () => {
|
||||
const { compact } = await summarizerHarness([], finish)
|
||||
let thrown: unknown
|
||||
try {
|
||||
await compact.summarize('history', agent(conversation(1), MODEL))
|
||||
await compact.runSummarize('history', agent(conversation(1), MODEL))
|
||||
} catch (error: unknown) {
|
||||
thrown = error
|
||||
}
|
||||
@@ -892,7 +894,7 @@ describe('default one-shot summarizer', () => {
|
||||
|
||||
it('rejects empty or reasoning-only successful output', async () => {
|
||||
const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
|
||||
await expect(compact.summarize('history', agent(conversation(1), MODEL)))
|
||||
await expect(compact.runSummarize('history', agent(conversation(1), MODEL)))
|
||||
.rejects.toThrow(/no text summary content/)
|
||||
})
|
||||
})
|
||||
@@ -973,7 +975,7 @@ describe('automatic listener and loader composition', () => {
|
||||
})
|
||||
const session = conversation(3)
|
||||
const beforeGeneration = session.surface.replaceGeneration
|
||||
const retainedSeq = session.surface.nodes.at(-1)!.seq
|
||||
const retainedSeq = session.surface.nodes.at(-1)!
|
||||
const threshold = 10_000
|
||||
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
|
||||
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
|
||||
@@ -981,7 +983,7 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(decision).toEqual({ action: 'retry' })
|
||||
expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
|
||||
expect(session.surface.nodes.some(node => node.seq === retainedSeq)).toBe(true)
|
||||
expect(session.surface.nodes).toContain(retainedSeq)
|
||||
})
|
||||
|
||||
it('authorizes overflow retry when pruning alone advances an indivisible surface', async () => {
|
||||
@@ -1078,8 +1080,8 @@ describe('automatic listener and loader composition', () => {
|
||||
const newestResult = session.surface.nodes.at(-1)!
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
|
||||
const currentAssistant = session.surface.nodes.find(node => node.seq === newestAssistant.seq)
|
||||
const currentResult = session.surface.nodes.find(node => node.seq === newestResult.seq)
|
||||
const currentAssistant = session.surface.nodes.find(node => node === newestAssistant)
|
||||
const currentResult = session.surface.nodes.find(node => node === newestResult)
|
||||
expect(currentAssistant).toBeDefined()
|
||||
expect(currentResult).toBeDefined()
|
||||
expect(toolPairingBalancedBefore(session, currentAssistant!)).toBe(true)
|
||||
@@ -1173,7 +1175,7 @@ describe('automatic listener and loader composition', () => {
|
||||
void new TestCompactService(ctx)
|
||||
const session = conversation(2)
|
||||
session.append('request/header', {
|
||||
header: { config: { model: 'unknown-routed-model' } },
|
||||
header: { config: { provider: 'unknown-routed-provider', model: 'unknown-routed-model' } },
|
||||
reason: 'resume',
|
||||
})
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* CBR-001 regression through the real loop. A replacement checkpoint has a high
|
||||
@@ -22,8 +21,12 @@ import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
*/
|
||||
|
||||
class ReproCompactService extends BasicCompactService {
|
||||
override async summarize(): Promise<{ summary: ContentBlock[]; model: string }> {
|
||||
return { summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }], model: 'stub' }
|
||||
override async summarize(): Promise<{ summary: ContentBlock[]; provider: string; model: string }> {
|
||||
return {
|
||||
summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }],
|
||||
provider: 'mock',
|
||||
model: 'stub',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +77,7 @@ class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
this.conversationRequests.push(options)
|
||||
if (this.conversationRequests.length === 1) {
|
||||
if (this.delivery === 'thrown') {
|
||||
throw new LlmError('request too large for model context', CONTEXT_WINDOW_EXCEEDED_CODE, 400)
|
||||
throw new LlmError('request too large for model context', CONTEXT_WINDOW_EXCEEDED_CODE)
|
||||
}
|
||||
yield {
|
||||
type: 'finish',
|
||||
@@ -94,12 +97,8 @@ class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
|
||||
async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 400 })
|
||||
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
|
||||
@@ -124,7 +123,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
||||
return { ctx, compact }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -138,9 +137,10 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
|
||||
it('uses the model actually routed by agent/request for post-step pressure', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' }))
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('routed-pressure'), {
|
||||
const agent = ctx.agentLoop.create(SessionId('routed-pressure'), {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
})
|
||||
agent.send([{ type: 'text', text: 'do a routed multi-step task' }])
|
||||
@@ -160,7 +160,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
it('runs automatic pressure after the current tool result and before step/end', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('post-step-order'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'do tool work' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'do a long multi-step task' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -203,12 +203,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()
|
||||
@@ -222,16 +222,12 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
async (delivery) => {
|
||||
const ctx = new Context()
|
||||
const adapter = new OverflowRecoveryAdapter(delivery)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' }))
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
|
||||
await ctx.plugin(BasicCompactService, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 100,
|
||||
@@ -241,7 +237,8 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId(`overflow-${delivery}`), {
|
||||
const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
})
|
||||
for (let turn = 1; turn <= 2; turn += 1) {
|
||||
@@ -256,6 +253,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('step/start', { turn, step: 1 })
|
||||
agent.session.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
|
||||
|
||||
@@ -121,10 +121,10 @@ export class ToolResultPruneService extends Service {
|
||||
*/
|
||||
pruneSession(session: Session): PruneResult {
|
||||
const candidates: SnapshotCandidate[] = []
|
||||
for (const node of [...session.surface.nodes]) {
|
||||
const event = session.events[node.seq]
|
||||
for (const seq of [...session.surface.nodes]) {
|
||||
const event = session.events[seq]
|
||||
/* v8 ignore next -- surface seqs are validated contiguous log references. */
|
||||
if (event?.type === 'tool/result') candidates.push({ seq: node.seq, event })
|
||||
if (event?.type === 'tool/result') candidates.push({ seq, event })
|
||||
}
|
||||
|
||||
const pruned: PrunedEntry[] = []
|
||||
|
||||
@@ -13,6 +13,7 @@ import ToolResultPruneService, {
|
||||
} from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
const MODEL = 'test-model'
|
||||
const SMALL: ToolResultPruneConfig = {
|
||||
thresholdChars: 50,
|
||||
headChars: 4,
|
||||
@@ -40,6 +41,7 @@ function appendToolStep(
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn, step: 1, callId, name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
@@ -185,7 +187,7 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq },
|
||||
sourceEventSeqs: [originalSeq],
|
||||
})
|
||||
expect(session.surface.nodes.some(node => node.seq === originalSeq)).toBe(false)
|
||||
expect(session.surface.nodes).not.toContain(originalSeq)
|
||||
})
|
||||
|
||||
it('prunes multiple results, skips short ones, and converges in one pass', () => {
|
||||
|
||||
@@ -19,15 +19,17 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
|
||||
| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. The agent must own the exact target (`session === agent.session`); a backend rejects mismatch before model resolution, lock acquisition, summarization, or log mutation. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
||||
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
||||
|
||||
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult).
|
||||
|
||||
`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 turn that the `compact/*` events belong to is recoverable from the owned session's 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, 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
|
||||
|
||||
@@ -35,7 +37,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
|
||||
|
||||
1. appends `compact/start` (log-only) — acquires the lock,
|
||||
2. summarizes the range,
|
||||
3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count,
|
||||
3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count, and provider/model call envelope,
|
||||
4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation**,
|
||||
5. appends `compact/end` (log-only) — releases the lock.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export type CompactionTrigger = 'pressure' | 'context-overflow'
|
||||
/** Minimal agent context compaction needs without depending on the agent package. */
|
||||
export interface CompactAgentContext {
|
||||
session: Session
|
||||
options: { model?: string }
|
||||
options: { provider?: string; model?: string }
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -49,7 +49,7 @@ export abstract class CompactService extends Service {
|
||||
* `null` when no safe range can be compacted. A single oversized retained
|
||||
* unit or request envelope cannot be repaired through surface compaction.
|
||||
*
|
||||
* @param agent - agent context owning the session surface and model options.
|
||||
* @param agent - agent context owning the session surface and routing options.
|
||||
* @param trigger - normal pressure or provider-confirmed context overflow.
|
||||
* @param signal - cancellation signal; model-backed implementations must forward it.
|
||||
* @returns the compaction result, or `null` if no compaction was needed.
|
||||
@@ -65,23 +65,19 @@ export abstract class CompactService extends Service {
|
||||
* `start` and `end` name an inclusive span by surface position, not numeric seq
|
||||
* order; replacements can make visible seqs non-monotonic. Both edges must be
|
||||
* balanced so assistant tool calls remain paired with their results. A model-
|
||||
* backed implementation forwards cancellation. The agent must own the exact
|
||||
* target session object; implementations reject an ownership mismatch before
|
||||
* model resolution, lock acquisition, summarization, or log mutation, and
|
||||
* reject active, missing, reversed, or unbalanced ranges.
|
||||
* backed implementation forwards cancellation and rejects active, missing,
|
||||
* reversed, or unbalanced ranges. The target session is `agent.session`.
|
||||
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
|
||||
* for the edge checks.
|
||||
*
|
||||
* @param session - session to mutate; must be identical to `agent.session`.
|
||||
* @param start - first surface seq, inclusive.
|
||||
* @param end - last surface seq, inclusive.
|
||||
* @param agent - owner of the target session and summarizer context.
|
||||
* @param agent - context whose session is mutated and whose routing options guide summarization.
|
||||
* @param signal - optional cancellation; model-backed implementations must forward it.
|
||||
* @throws when the agent does not own `session`, compaction is active, or the range is missing, reversed, or unbalanced.
|
||||
* @returns the replaced range and summary.
|
||||
* @throws when compaction is active or the range is missing, reversed, or unbalanced.
|
||||
* @returns the appended event seqs, summary, replaced range, and token accounting.
|
||||
*/
|
||||
abstract compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
agent: CompactAgentContext,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
shadowedRange: { start: number; end: number }
|
||||
shadowedSeqs: number[]
|
||||
shadowedTokenCount: number
|
||||
/** The provider route that wrote the summary. */
|
||||
provider: string
|
||||
/**
|
||||
* The model that wrote the summary — the summarize call's envelope,
|
||||
* reported by the backend that made the call, logged so the one-shot
|
||||
|
||||
@@ -25,20 +25,22 @@ class StubCompactService extends CompactService {
|
||||
}
|
||||
|
||||
override async compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
_agent: CompactAgentContext,
|
||||
agent: CompactAgentContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
this.lastSignal = signal
|
||||
const session = agent.session
|
||||
const summary = [{ type: 'text' as const, text: 'stub' }]
|
||||
// Minimal stub honoring the lock + log-only event contract.
|
||||
const startEvent = session.append('compact/start', { turn: 0 })
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary: [{ type: 'text', text: 'stub' }],
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [],
|
||||
shadowedTokenCount: 0,
|
||||
provider: 'mock',
|
||||
model: 'stub',
|
||||
})
|
||||
const endEvent = session.append('compact/end', { turn: 0 })
|
||||
@@ -46,7 +48,7 @@ class StubCompactService extends CompactService {
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
summary: [{ type: 'text', text: 'stub' }],
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [],
|
||||
shadowedTokenCount: 0,
|
||||
@@ -86,7 +88,7 @@ describe('CompactService seam', () => {
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
|
||||
const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'))
|
||||
const result = await svc.compactRegion(0, 0, stubAgent(session, 'm'))
|
||||
|
||||
const startEvent = session.events.find(e => e.type === 'compact/start')
|
||||
expect(startEvent).toBeDefined()
|
||||
@@ -94,8 +96,12 @@ describe('CompactService seam', () => {
|
||||
// verify the runtime value is absent.
|
||||
const raw = startEvent as unknown as { surfaceOp?: unknown }
|
||||
expect(raw.surfaceOp).toBeUndefined()
|
||||
expect(result.summary).toEqual([{ type: 'text', text: 'stub' }])
|
||||
expect(result.summarySeq).toBeGreaterThan(result.startSeq)
|
||||
expect(result.endSeq).toBeGreaterThan(result.summarySeq)
|
||||
expect(result.shadowedRange).toEqual({ start: 0, end: 0 })
|
||||
expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type))
|
||||
.toEqual(['compact/start', 'compact/summary', 'compact/end'])
|
||||
})
|
||||
|
||||
it('threads the cancellation signal through to the backend', async () => {
|
||||
@@ -104,7 +110,7 @@ describe('CompactService seam', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const controller = new AbortController()
|
||||
|
||||
await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal)
|
||||
await svc.compactRegion(0, 0, stubAgent(session, 'm'), controller.signal)
|
||||
expect(svc.lastSignal).toBe(controller.signal)
|
||||
|
||||
await svc.compactIfNeeded(stubAgent(session), 'context-overflow', controller.signal)
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('renderTranscript', () => {
|
||||
content: [{ type: 'text', text: 'fix the bug' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const assistant = s.append('assistant/message', {
|
||||
const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: 'looking' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -111,7 +111,7 @@ describe('renderTranscript', () => {
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyAssistant = s.append('assistant/message', {
|
||||
const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
@@ -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 {
|
||||
@@ -34,6 +34,7 @@ function closedToolStep(): Session {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
@@ -60,6 +61,7 @@ describe('tool-pairing boundaries', () => {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
|
||||
})
|
||||
@@ -73,6 +75,7 @@ describe('tool-pairing boundaries', () => {
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
|
||||
],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
@@ -91,6 +94,7 @@ describe('tool-pairing boundaries', () => {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, SURFACE)
|
||||
midStep.append('context/message', {
|
||||
content: [{ type: 'text', text: 'background update' }],
|
||||
@@ -113,16 +117,19 @@ 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
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes.at(-1)!.seq } })
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes.at(-1)! },
|
||||
sourceEventSeqs: [...nodes],
|
||||
})
|
||||
|
||||
const checkpoint = session.surface.nodes[0]!
|
||||
expect(toolPairingBalancedBefore(session, checkpoint)).toBe(true)
|
||||
@@ -131,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/)
|
||||
|
||||
@@ -162,7 +169,12 @@ describe('tool-pairing cache refresh', () => {
|
||||
},
|
||||
{
|
||||
type: 'assistant/message', seq: 1, time: 1,
|
||||
data: { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }] },
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
@@ -171,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
|
||||
@@ -219,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)
|
||||
@@ -227,7 +235,12 @@ describe('tool-pairing cache refresh', () => {
|
||||
events.push(
|
||||
{
|
||||
type: 'assistant/message', seq: 5, time: 5,
|
||||
data: { turn: 2, step: 1, content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }] },
|
||||
data: {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
@@ -236,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)
|
||||
@@ -249,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,
|
||||
@@ -267,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 },
|
||||
@@ -304,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