Merge remote-tracking branch 'origin/master' into codex/invariant-service-seam

This commit is contained in:
Tianyi Cui
2026-07-20 19:42:41 +08:00
389 changed files with 15289 additions and 8529 deletions

View File

@@ -0,0 +1,60 @@
# @deepseek-ai/dsh-compact-tool-result-prune
The replay-safe model-free pruning service (`ctx.toolResultPrune`). It rewrites over-budget `tool/result` surface nodes to a bounded head, a fixed omission marker, and a bounded tail while retaining the full original event in the append-only session log.
This is a concrete companion to [`dsh-compact-basic`](../compact-basic/README.md), not a compaction backend or model-facing tool. Compact-basic reads it through optional `ctx.get('toolResultPrune')`, so either package remains independently composable.
## Service API
`pruneSession(session)` scans one stable snapshot of the current surface. Every over-budget tool result is replaced by one newly appended `tool/result` carrying `{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }`. The replacement spreads the complete original data and changes only `content`, preserving `turn`, `step`, `callId`, error fields, `meta`, and later data additions. The original event remains available for persistence, replay, and exact-log inspection.
The method throws synchronously when the session rejects a replacement. Replacements committed earlier in the pass remain durable.
`measureContent(blocks)` counts Unicode code points in `text` blocks. `pruneContent(blocks)` returns the bounded replacement or `null` when content is already within the threshold. Non-text blocks are retained at their original relative positions; text slicing never splits a UTF-16 surrogate pair, though it can split a multi-code-point grapheme cluster.
Every emitted result has exactly the configured head budget, fixed marker, and tail budget in text code points, is no larger than `thresholdChars`, and is strictly smaller than the triggering input. A second pass therefore emits no replacement.
## Config
Unrecognized keys fail at plugin construction. Resolved config is detached and deeply immutable.
| Key | Required | Meaning |
|---|---|---|
| `thresholdChars` | no (default `8192`) | Prune when combined text exceeds this many Unicode code points. |
| `headChars` | no (default `4096`) | Leading Unicode code points retained. |
| `tailChars` | no (default `1024`) | Trailing Unicode code points retained. |
All values are integers; the threshold is positive and head/tail are non-negative. `headChars + marker + tailChars` must fit within `thresholdChars`, so a valid configuration can prune every over-budget result without growth or repeated rewriting.
## Usage
```ts
import type { Context } from 'cordis'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
export function apply(ctx: Context): void {
ctx.plugin(ToolResultPruneService)
}
```
## Model Experience
### Pruned tool result
#### What the model sees
Once a compaction trigger qualifies, future requests see the retained head, `\n\n[... tool result middle pruned ...]\n\n`, and retained tail in place of the removed text. Rich blocks keep their order. The model does not see a second copy of the original.
#### Token effect
Each rewritten tool result has at most `thresholdChars` text code points. Pruning itself makes no model call; compact-basic skips summarization when the remeasured request falls below pressure, otherwise the summarizer reads the pruned surface.
#### KV Cache effect
Replacing an earlier result invalidates reuse from the first changed token. The pruned prefix is eligible for reuse while its route, envelope, and preceding history remain identical.
## Known Limitations and Deferred Work
- **Character budgets are not token budgets** — provider token density varies, so `ctx.tokenMeter` remains the authority for deciding whether pruning relieved request pressure.
- **Pruning is syntactic** — it retains the beginning and end without interpreting which middle lines are semantically important.
- **Grapheme clusters can split** — code-point slicing protects surrogate pairs but does not perform locale-aware grapheme segmentation.

View File

@@ -0,0 +1,40 @@
{
"name": "@deepseek-ai/dsh-compact-tool-result-prune",
"description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,77 @@
/** Configuration resolution for deterministic tool-result pruning. */
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ResolvedConfig, ToolResultPruneConfig } from './types.ts'
/** Fixed marker substituted for every removed middle span. */
export const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n'
/** Low-friction defaults for coding-agent tool output. */
export const DEFAULTS: ResolvedConfig = deepFreeze({
thresholdChars: 8192,
headChars: 4096,
tailChars: 1024,
})
const CONFIG_KEYS: ReadonlySet<string> = new Set([
'thresholdChars',
'headChars',
'tailChars',
])
/**
* Count Unicode code points without splitting surrogate pairs.
* @param text - text to measure.
* @returns the Unicode code-point count.
*/
export function codePointLength(text: string): number {
return Array.from(text).length
}
/**
* Resolve and validate pruning budgets.
* @param config - raw plugin configuration.
* @returns a detached deeply immutable configuration.
*/
export function resolveConfig(config: ToolResultPruneConfig = {}): ResolvedConfig {
for (const key of Object.keys(config)) {
if (!CONFIG_KEYS.has(key)) {
throw new Error(
`ToolResultPruneConfig: unknown key "${key}" `
+ '(allowed: thresholdChars, headChars, tailChars)',
)
}
}
const resolved: ResolvedConfig = {
thresholdChars: config.thresholdChars ?? DEFAULTS.thresholdChars,
headChars: config.headChars ?? DEFAULTS.headChars,
tailChars: config.tailChars ?? DEFAULTS.tailChars,
}
assertPositiveInteger('thresholdChars', resolved.thresholdChars)
assertNonNegativeInteger('headChars', resolved.headChars)
assertNonNegativeInteger('tailChars', resolved.tailChars)
const emittedChars = resolved.headChars
+ codePointLength(PRUNE_MARKER)
+ resolved.tailChars
if (emittedChars > resolved.thresholdChars) {
throw new Error(
`ToolResultPruneConfig: headChars + marker + tailChars (${emittedChars}) `
+ `must be at most thresholdChars (${resolved.thresholdChars})`,
)
}
return deepFreeze(structuredClone(resolved))
}
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a positive integer`)
}
}
function assertNonNegativeInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a non-negative integer`)
}
}

View File

@@ -0,0 +1,159 @@
/**
* Replay-safe, model-free tool-result pruning service.
*
* @module @deepseek-ai/dsh-compact-tool-result-prune
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
import type {
PrunedEntry,
PruneResult,
ResolvedConfig,
ToolResultPruneConfig,
} from './types.ts'
export { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
export type {
PrunedEntry,
PruneResult,
ResolvedConfig,
ToolResultPruneConfig,
} from './types.ts'
declare module 'cordis' {
interface Context {
toolResultPrune: ToolResultPruneService
}
}
interface SnapshotCandidate {
readonly seq: number
readonly event: SessionEvent<'tool/result'>
}
/** Deterministic head/middle/tail pruning for current tool-result surface nodes. */
export class ToolResultPruneService extends Service {
static Config: z<ToolResultPruneConfig> = z.object({
thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars),
headChars: z.number().step(1).min(0).default(DEFAULTS.headChars),
tailChars: z.number().step(1).min(0).default(DEFAULTS.tailChars),
})
/** Resolved and immutable character budgets. */
readonly config: ResolvedConfig
constructor(ctx: Context, config: ToolResultPruneConfig = {}) {
super(ctx, 'toolResultPrune')
this.config = resolveConfig(config)
}
/**
* Measure text content in Unicode code points; non-text blocks cost zero.
* @param blocks - tool-result content to measure.
* @returns total Unicode code points across text blocks.
*/
measureContent(blocks: readonly ContentBlock[]): number {
let chars = 0
for (const block of blocks) {
if (block.type === 'text') chars += codePointLength(block.text)
}
return chars
}
/**
* Replace an over-budget text middle while retaining rich-block order.
* Text slicing is by Unicode code point, not UTF-16 code unit, so a retained
* boundary cannot split a surrogate pair. Grapheme clusters may still split.
* @param blocks - original tool-result content.
* @returns pruned content, or `null` when the text is within budget.
*/
pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null {
const totalChars = this.measureContent(blocks)
if (totalChars <= this.config.thresholdChars) return null
const removedStart = this.config.headChars
const removedEnd = totalChars - this.config.tailChars
const pruned: ContentBlock[] = []
let consumed = 0
let markerInserted = false
for (const block of blocks) {
if (block.type !== 'text') {
pruned.push(block)
continue
}
const points = Array.from(block.text)
const blockStart = consumed
const blockEnd = blockStart + points.length
const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart))
const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart))
const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart
const marker = intersectsRemoved && !markerInserted ? PRUNE_MARKER : ''
if (marker.length > 0) markerInserted = true
const text = points.slice(0, headEnd).join('')
+ marker
+ points.slice(tailStart).join('')
if (text.length > 0) pruned.push({ ...block, text })
consumed = blockEnd
}
/* v8 ignore next -- totalChars > threshold and valid budgets guarantee a removed text span. */
if (!markerInserted) throw new Error('tool-result prune: failed to locate the removed text span')
const charsAfter = this.measureContent(pruned)
/* v8 ignore next -- config validation fixes the emitted head + marker + tail budget. */
if (charsAfter > this.config.thresholdChars || charsAfter >= totalChars) {
throw new Error('tool-result prune: replacement must be smaller and within threshold')
}
return pruned
}
/**
* Prune every over-budget tool result from one stable current-surface snapshot.
* Each replacement preserves the complete event data except for `content`,
* and points at the shadowed node for durable provenance and replay.
* @param session - session whose current surface is rewritten.
* @returns landed replacements and aggregate Unicode-code-point savings.
* @throws when the session rejects a replacement; replacements committed
* earlier in the pass remain durable.
*/
pruneSession(session: Session): PruneResult {
const candidates: SnapshotCandidate[] = []
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, event })
}
const pruned: PrunedEntry[] = []
let charsRemoved = 0
for (const { seq, event } of candidates) {
const content = this.pruneContent(event.data.content)
if (content === null) continue
const charsBefore = this.measureContent(event.data.content)
const charsAfter = this.measureContent(content)
const replacement = session.append('tool/result', {
...event.data,
content,
}, {
surfaceOp: { op: 'replace', start: seq, end: seq },
sourceEventSeqs: [seq],
})
pruned.push({
originalSeq: seq,
replacementSeq: replacement.seq,
callId: event.data.callId,
charsBefore,
charsAfter,
})
charsRemoved += charsBefore - charsAfter
}
return { pruned, charsRemoved }
}
}
export default ToolResultPruneService

View File

@@ -0,0 +1,40 @@
import type { CallId } from '@deepseek-ai/dsh-llm'
/** Character-budget policy for deterministic tool-result pruning. */
export interface ToolResultPruneConfig {
/** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */
thresholdChars?: number
/** Maximum leading Unicode code points retained. Defaults to `4096`. */
headChars?: number
/** Maximum trailing Unicode code points retained. Defaults to `1024`. */
tailChars?: number
}
/** Validated, detached, deeply immutable pruning configuration. */
export interface ResolvedConfig {
readonly thresholdChars: number
readonly headChars: number
readonly tailChars: number
}
/** Provenance and size accounting for one landed surface replacement. */
export interface PrunedEntry {
/** Full-fidelity tool-result event shadowed by the replacement. */
readonly originalSeq: number
/** Newly appended pruned tool-result event. */
readonly replacementSeq: number
/** Tool call shared by the original and replacement. */
readonly callId: CallId
/** Original text size in Unicode code points. */
readonly charsBefore: number
/** Replacement text size in Unicode code points. */
readonly charsAfter: number
}
/** Aggregate outcome of one stable-surface pruning pass. */
export interface PruneResult {
/** Replacements in the snapshotted surface order. */
readonly pruned: readonly PrunedEntry[]
/** Total Unicode code points removed across replacements. */
readonly charsRemoved: number
}

View File

@@ -0,0 +1,67 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('compact-tool-result-prune real Loader composition', () => {
it('loads and resolves the flat YAML plugin shape', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
' config:',
' thresholdChars: 100',
' headChars: 20',
' tailChars: 10',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') {
throw new Error(`unexpected Loader import: ${specifier}`)
}
return ToolResultPruneService
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
expect(context.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
expect(context.toolResultPrune.config).toEqual({
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
})
it('rejects stale config after plugin schema normalization', async () => {
context = new Context()
await expect(context.plugin(ToolResultPruneService, {
maxChars: 100,
} as never)).rejects.toThrow(/unknown key "maxChars"/)
})
})

View File

@@ -0,0 +1,241 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
import ToolResultPruneService, {
codePointLength,
DEFAULTS,
PRUNE_MARKER,
resolveConfig,
} 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,
tailChars: 3,
}
function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService {
return new ToolResultPruneService(new Context(), config)
}
function appendToolStep(
session: Session,
turn: number,
call: string,
content: ContentBlock[],
extra: Record<string, unknown> = {},
): number {
const callId = CallId(call)
session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('step/start', { turn, step: 1 })
session.append('assistant/message', {
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', {
turn,
step: 1,
callId,
content,
isError: false,
...extra,
}, { surfaceOp: 'append' })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
return result.seq
}
describe('tool-result pruning configuration', () => {
it('resolves detached immutable defaults and partial overrides', () => {
const raw = { thresholdChars: 100, headChars: 20, tailChars: 10 }
const resolved = resolveConfig(raw)
raw.headChars = 1
expect(resolved).toEqual({ thresholdChars: 100, headChars: 20, tailChars: 10 })
expect(Object.isFrozen(resolved)).toBe(true)
expect(DEFAULTS).toEqual({ thresholdChars: 8192, headChars: 4096, tailChars: 1024 })
expect(Object.isFrozen(DEFAULTS)).toBe(true)
})
it('rejects stale keys, invalid scalars, and an output budget above threshold', () => {
const bad = [
[{ thresholdChars: 0 }, /thresholdChars .* positive integer/],
[{ headChars: -1 }, /headChars .* non-negative integer/],
[{ tailChars: 1.5 }, /tailChars .* non-negative integer/],
[{ thresholdChars: 50, headChars: 20, tailChars: 20 }, /headChars \+ marker \+ tailChars/],
[{ threshold: 10 }, /unknown key "threshold"/],
] as Array<[unknown, RegExp]>
for (const [config, pattern] of bad) {
expect(() => resolveConfig(config as ToolResultPruneConfig)).toThrow(pattern)
}
})
})
describe('ToolResultPruneService content transform', () => {
it('measures text code points only and skips content within threshold', () => {
const prune = service()
const blocks = [
{ type: 'text', text: 'a😀b' },
{ type: 'reasoning', text: 'not measured' },
] satisfies ContentBlock[]
expect(prune.measureContent(blocks)).toBe(3)
expect(prune.pruneContent(blocks)).toBeNull()
expect(codePointLength('a😀b')).toBe(3)
})
it('keeps configured head and tail without splitting surrogate pairs', () => {
const prune = service()
const result = prune.pruneContent([{ type: 'text', text: '😀'.repeat(60) }])
expect(result).toEqual([{
type: 'text',
text: `${'😀'.repeat(4)}${PRUNE_MARKER}${'😀'.repeat(3)}`,
}])
expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
expect(result![0]).toMatchObject({ type: 'text' })
expect((result![0] as { text: string }).text).not.toContain('\uFFFD')
})
it('preserves non-text blocks and their relative ordering across removed text', () => {
const prune = service()
const reasoning: ContentBlock = { type: 'reasoning', text: 'private-rich-block' }
const call: ContentBlock = {
type: 'tool-call',
id: CallId('nested'),
name: 'nested',
arguments: '{}',
}
const result = prune.pruneContent([
{ type: 'text', text: 'A'.repeat(40) },
reasoning,
{ type: 'text', text: 'B'.repeat(30) },
call,
{ type: 'text', text: 'C'.repeat(30) },
])
expect(result).toEqual([
{ type: 'text', text: `AAAA${PRUNE_MARKER}` },
reasoning,
call,
{ type: 'text', text: 'CCC' },
])
expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
})
it('supports zero-sized head and tail while still shrinking', () => {
const prune = service({
thresholdChars: codePointLength(PRUNE_MARKER),
headChars: 0,
tailChars: 0,
})
const result = prune.pruneContent([{ type: 'text', text: 'x'.repeat(100) }])
expect(result).toEqual([{ type: 'text', text: PRUNE_MARKER }])
expect(prune.measureContent(result!)).toBe(prune.config.thresholdChars)
})
})
describe('ToolResultPruneService session transaction', () => {
it('prunes a stable snapshot, preserves all data, and records provenance', () => {
const session = new Session(SessionId('preserve'))
const originalSeq = appendToolStep(session, 1, 'one', [{
type: 'text',
text: 'x'.repeat(100),
}], {
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { diff: ['a', 'b'] },
futureField: { nested: true },
})
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const result = service().pruneSession(session)
expect(result.pruned).toHaveLength(1)
expect(result.charsRemoved).toBeGreaterThan(0)
const entry = result.pruned[0]!
expect(entry).toMatchObject({ originalSeq, callId: CallId('one'), charsBefore: 100 })
expect(entry.charsAfter).toBeLessThanOrEqual(50)
const original = session.events[originalSeq]!
const replacement = session.events[entry.replacementSeq]! as SurfaceEvent
expect(original).toMatchObject({
type: 'tool/result',
data: { content: [{ type: 'text', text: 'x'.repeat(100) }] },
})
expect(replacement).toMatchObject({
type: 'tool/result',
data: {
turn: 1,
step: 1,
callId: CallId('one'),
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { diff: ['a', 'b'] },
futureField: { nested: true },
},
surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq },
sourceEventSeqs: [originalSeq],
})
expect(session.surface.nodes).not.toContain(originalSeq)
})
it('prunes multiple results, skips short ones, and converges in one pass', () => {
const session = new Session(SessionId('multiple'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }])
appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }])
session.append('turn/start', {
turn: 4,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const prune = service()
const first = prune.pruneSession(session)
const second = prune.pruneSession(session)
expect(first.pruned.map(entry => entry.callId)).toEqual([CallId('a'), CallId('c')])
expect(first.charsRemoved).toBe(
first.pruned.reduce((sum, entry) => sum + entry.charsBefore - entry.charsAfter, 0),
)
expect(second).toEqual({ pruned: [], charsRemoved: 0 })
})
it('replays to the identical pruned model messages', () => {
const session = new Session(SessionId('replay'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
service().pruneSession(session)
const replay = new Session(session.id, [...session.events])
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration)
})
it('runs under real invariants between closed steps but not outside a turn', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
const prune = new ToolResultPruneService(ctx, SMALL)
const session = ctx.sessions.create(SessionId('invariants'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
expect(() => prune.pruneSession(session)).toThrow(/outside any open turn/)
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(() => prune.pruneSession(session)).not.toThrow()
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../llm/llm" },
{ "path": "../../core/session" }
]
}