From 387f7372783bbdd9b3b3cde2ee220c630c999735 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:36:52 +0000 Subject: [PATCH 1/2] refactor(eviction): lift canonical-surface eviction core out of the DSH adapter Move the host-neutral pieces of DeepSeek Harness eviction into @lightrsi/eviction (canonical-surface/): tool call/result closure, the independent safety policy, registry task-state classification, the estimate -> registry -> safety -> transaction cycle, and estimator construction helpers. Item ids are generic so transcript-based hosts (pi, OpenCode) can use entry ids instead of DSH event seqs. The DSH adapter keeps its public API and now delegates to the shared module; only DSH event parsing, native replacement envelopes and the surfaceOp transaction stay in the adapter. No behaviour change: DSH tests 110/110, eviction tests 62/62 (9 new). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S6it2Si8k8XNGMF8RdyMQz --- .../deepseek-harness/src/eviction-cycle.ts | 181 ++++------------ .../src/lifecycle-estimator.ts | 9 +- .../deepseek-harness/src/safety-policy.ts | 105 ++-------- .../deepseek-harness/src/tool-closure.ts | 43 ++-- .../src/canonical-surface/estimator-config.ts | 25 +++ .../src/canonical-surface/eviction-cycle.ts | 169 +++++++++++++++ .../eviction/src/canonical-surface/index.ts | 4 + .../src/canonical-surface/safety-policy.ts | 148 +++++++++++++ .../src/canonical-surface/tool-closure.ts | 92 ++++++++ .../packages/features/eviction/src/index.ts | 1 + .../eviction/tests/canonical-surface.test.ts | 197 ++++++++++++++++++ 11 files changed, 723 insertions(+), 251 deletions(-) create mode 100644 components/packages/features/eviction/src/canonical-surface/estimator-config.ts create mode 100644 components/packages/features/eviction/src/canonical-surface/eviction-cycle.ts create mode 100644 components/packages/features/eviction/src/canonical-surface/index.ts create mode 100644 components/packages/features/eviction/src/canonical-surface/safety-policy.ts create mode 100644 components/packages/features/eviction/src/canonical-surface/tool-closure.ts create mode 100644 components/packages/features/eviction/tests/canonical-surface.test.ts diff --git a/components/adapters/deepseek-harness/src/eviction-cycle.ts b/components/adapters/deepseek-harness/src/eviction-cycle.ts index 219496a6..9a1f9528 100644 --- a/components/adapters/deepseek-harness/src/eviction-cycle.ts +++ b/components/adapters/deepseek-harness/src/eviction-cycle.ts @@ -13,15 +13,11 @@ * `mapTaskUpdatesToRegistryPatch` + `applySessionTaskRegistryPatch`. */ -import { - applySessionTaskRegistryPatch, - type SessionTaskRegistry, -} from "@lightrsi/history"; -import { mapTaskUpdatesToRegistryPatch, type TaskStateEstimator } from "@lightrsi/eviction"; +import type { SessionTaskRegistry } from "@lightrsi/history"; +import { runCanonicalEvictionCycle, type TaskStateEstimator } from "@lightrsi/eviction"; -import { buildDshDeltaView, buildDshRawSemanticSnapshot } from "./session-codec.js"; -import { runTaskStateEstimate } from "./lifecycle-estimator.js"; -import { applySafetyPolicy, type SafetyItem, type TaskState } from "./safety-policy.js"; +import { buildDshRawSemanticSnapshot } from "./session-codec.js"; +import type { SafetyItem } from "./safety-policy.js"; import { applyEvictionTransaction, type AppendableSession, @@ -30,7 +26,7 @@ import { type TransactionResult, } from "./surface-transaction.js"; import type { DshLogEventWithMeta, DshMessage } from "./types.js"; -import { assistantCallIds, resultCallId } from "./tool-closure.js"; +import { assistantCallIds, buildToolPairs, resultCallId } from "./tool-closure.js"; /** The session view the cycle needs: durable log + surface + append (R4). */ export interface CycleSession extends AppendableSession { @@ -125,28 +121,6 @@ export function describeEffectiveItems( return items; } -/** Classify an item's task state from the registry (never re-inferred from content). */ -function classify( - item: EffectiveItem, - registry: SessionTaskRegistry, - currentTurn: number, -): { taskState: TaskState; current: boolean } { - if (item.turn >= currentTurn) return { taskState: "current", current: true }; - - const turnAbsId = `${registry.sessionId}:t${item.turn}`; - const taskIds = registry.turnToTaskIds[turnAbsId] ?? []; - const evictable = new Set(registry.evictableTaskIds); - const completed = new Set(registry.completedTaskIds); - const active = new Set(registry.activeTaskIds); - - // Removable only if EVERY owning task is completed/evictable and none active. - if (taskIds.length > 0 && taskIds.every((id) => evictable.has(id) || completed.has(id)) && !taskIds.some((id) => active.has(id))) { - return { taskState: "completed", current: false }; - } - if (taskIds.some((id) => active.has(id))) return { taskState: "active", current: false }; - return { taskState: "unresolved", current: false }; -} - function currentTurnOf(events: readonly DshLogEventWithMeta[]): number { let turn = 0; for (const e of events) { @@ -156,7 +130,11 @@ function currentTurnOf(events: readonly DshLogEventWithMeta[]): number { return turn; } -/** Run one eviction cycle. Registry in → updated registry + transaction result out. */ +/** + * Run one eviction cycle. Registry in → updated registry + transaction result out. + * The cycle itself is the shared `runCanonicalEvictionCycle`; this wrapper + * supplies DSH's snapshot, surface items, tool pairs, and native transaction. + */ export async function runDshEvictionCycle(params: { session: CycleSession; registry: SessionTaskRegistry; @@ -168,123 +146,56 @@ export async function runDshEvictionCycle(params: { allowSurfaceMutation?: boolean; persistRegistry?: (registry: SessionTaskRegistry, expectedVersion: number) => void | Promise; }): Promise { - const { session, estimator, computeRevision } = params; + const { session, computeRevision } = params; const snapshot = buildDshRawSemanticSnapshot(session.id, session.events, { surfaceEventSeqs: session.surface.nodes, }); - const delta = buildDshDeltaView(snapshot, { fromTurnSeqExclusive: params.registry.lastProcessedTurnSeq }); - let registry = params.registry; - let registryPersistedBeforeMutation = false; - let registryExpectedVersion = params.registry.version; - if (delta.coveredTurnAbsIds.length > 0) { - // Estimator → registry update (shared mapper; no registry logic reinvented). - const output = await runTaskStateEstimate(estimator, { registry: params.registry, delta }); - const { patch } = mapTaskUpdatesToRegistryPatch({ - registry: params.registry, - updates: output.taskUpdates, - coveredTurnAbsIds: delta.coveredTurnAbsIds, - toTurnSeqInclusive: delta.toTurnSeqInclusive, - }); - registry = applySessionTaskRegistryPatch(params.registry, patch); - // Registry CAS is a precondition for canonical mutation. Persist task state - // first, but keep the watermark behind until the surface transaction lands; - // otherwise a partial append would permanently hide its unprocessed tail. - const pendingRegistry = { - ...registry, - lastProcessedTurnSeq: params.registry.lastProcessedTurnSeq, - }; - await params.persistRegistry?.(pendingRegistry, params.registry.version); - registry = pendingRegistry; - registryPersistedBeforeMutation = params.persistRegistry !== undefined; - registryExpectedVersion = pendingRegistry.version; - } - - // Classify effective items against the updated registry, then R3 safety filter. const currentTurn = currentTurnOf(session.events); const effective = describeEffectiveItems(session.events, session.surface.nodes); const bySeq = new Map(effective.map((it) => [it.seq, it])); - const safetyItems: SafetyItem[] = effective.map((it) => { - const c = classify(it, registry, currentTurn); - return { + const evictionId = params.evictionId ?? `dsh-evict-${session.id}-${currentTurn}`; + + const cycle = await runCanonicalEvictionCycle({ + snapshot, + registry: params.registry, + estimator: params.estimator, + items: effective.map((it) => ({ sourceEventSeq: it.seq, + turn: it.turn, kind: it.kind, - taskState: c.taskState, - current: c.current, callIds: it.callIds, chars: it.chars, - }; - }); - - const decision = applySafetyPolicy( - safetyItems, - session.surface.nodes, - session.events, - params.minBlockChars ?? 0, - ); - if (decision.evictSeqs.length === 0) { - registry = { ...registry, lastProcessedTurnSeq: delta.toTurnSeqInclusive }; - if (registryPersistedBeforeMutation && params.persistRegistry) { - await params.persistRegistry(registry, registryExpectedVersion); - } - return { - registry, - result: emptyResult(), - registryPersisted: true, - status: delta.coveredTurnAbsIds.length === 0 ? "no-delta" : "empty", - }; - } - - // Context Cleaner needs the same completed-task registry as automatic - // eviction, but a Cleaner-only profile must never remove context before the - // user makes an explicit selection. Advance the durable lifecycle - // watermark after a successful estimate while leaving DSH's surface intact. - if (params.allowSurfaceMutation === false) { - registry = { ...registry, lastProcessedTurnSeq: delta.toTurnSeqInclusive }; - if (registryPersistedBeforeMutation && params.persistRegistry) { - await params.persistRegistry(registry, registryExpectedVersion); - } - return { - registry, - result: emptyResult(), - registryPersisted: true, - status: "empty", - }; - } - - // Build the plan and apply it as a canonical transaction (R4). - const targets: EvictionTarget[] = decision.evictSeqs.map((seq) => { - const it = bySeq.get(seq); - if (!it) throw new Error(`eviction target ${seq} is not a current surface item`); - return buildEvictionTarget(it, params.evictionId ?? `dsh-evict-${session.id}-${currentTurn}`); + })), + effectiveSeqs: session.surface.nodes, + pairs: buildToolPairs(session.events, session.surface.nodes), + currentTurn, + minBlockChars: params.minBlockChars, + allowSurfaceMutation: params.allowSurfaceMutation, + compareSeqs: (a, b) => a - b, + persistRegistry: params.persistRegistry, + emptyResult, + apply(evictSeqs) { + // Build the plan and apply it as a canonical transaction (R4). + const targets: EvictionTarget[] = evictSeqs.map((seq) => { + const it = bySeq.get(seq); + if (!it) throw new Error(`eviction target ${seq} is not a current surface item`); + return buildEvictionTarget(it, evictionId); + }); + const plan: EvictionPlan = { + evictionId, + revision: computeRevision(session), + targets, + }; + return applyEvictionTransaction(session, plan, computeRevision); + }, }); - const plan: EvictionPlan = { - evictionId: params.evictionId ?? `dsh-evict-${session.id}-${currentTurn}`, - revision: computeRevision(session), - targets, - }; - const result = applyEvictionTransaction(session, plan, computeRevision); - - const shouldAdvanceWatermark = result.status === "committed"; - const finalRegistry = shouldAdvanceWatermark - ? { ...registry, lastProcessedTurnSeq: delta.toTurnSeqInclusive } - : registry; - let registryPersisted = true; - if (shouldAdvanceWatermark && registryPersistedBeforeMutation && params.persistRegistry) { - try { - await params.persistRegistry(finalRegistry, registryExpectedVersion); - } catch { - // Replacements already landed. Keep the in-memory result truthful and let - // the next cycle recover the stale watermark from the canonical surface. - registryPersisted = false; - } - } return { - registry: finalRegistry, - result, - registryPersisted, - status: result.status === "committed" || result.status === "partial" ? "applied" : "deferred", + registry: cycle.registry, + result: cycle.result, + registryPersisted: cycle.registryPersisted, + status: cycle.status, }; } diff --git a/components/adapters/deepseek-harness/src/lifecycle-estimator.ts b/components/adapters/deepseek-harness/src/lifecycle-estimator.ts index 6584ebd2..098022c0 100644 --- a/components/adapters/deepseek-harness/src/lifecycle-estimator.ts +++ b/components/adapters/deepseek-harness/src/lifecycle-estimator.ts @@ -12,7 +12,8 @@ */ import { - createApiTaskStateEstimator, + createConfiguredTaskStateEstimator, + isTaskStateEstimatorConfigured, type TaskStateEstimator, type TaskStateEstimatorApiConfig, type TaskStateEstimatorInput, @@ -23,7 +24,7 @@ import type { DshEstimatorConfig } from "./config.js"; /** True only when the estimator has the endpoint + credentials it needs to run. */ export function isEstimatorConfigured(cfg: DshEstimatorConfig): boolean { - return Boolean(cfg.baseUrl && cfg.apiKey && cfg.model); + return isTaskStateEstimatorConfigured(cfg); } /** Map the DSH adapter config onto the shared estimator config (field-for-field). */ @@ -48,9 +49,7 @@ export function toEstimatorApiConfig(cfg: DshEstimatorConfig): TaskStateEstimato * lets the pre-step handler fail open and defer. */ export function createDshTaskStateEstimator(cfg: DshEstimatorConfig): TaskStateEstimator | undefined { - if (cfg.enabled === false) return undefined; - if (!isEstimatorConfigured(cfg)) return undefined; - return createApiTaskStateEstimator(toEstimatorApiConfig(cfg)); + return createConfiguredTaskStateEstimator(toEstimatorApiConfig(cfg)); } /** Run the shared estimator over the codec-produced input. Output is opaque here. */ diff --git a/components/adapters/deepseek-harness/src/safety-policy.ts b/components/adapters/deepseek-harness/src/safety-policy.ts index 5964c8b4..cad027a1 100644 --- a/components/adapters/deepseek-harness/src/safety-policy.ts +++ b/components/adapters/deepseek-harness/src/safety-policy.ts @@ -16,24 +16,23 @@ * (tests/fixtures/session-events.json). */ -import { buildToolPairs, isEvictablePair, type ClosureEvent } from "./tool-closure.js"; +import { + applySurfaceSafetyPolicy, + type SurfaceItemKind, + type SurfaceSafetyAction, + type SurfaceSafetyItem, + type SurfaceTaskState, +} from "@lightrsi/eviction"; -export type TaskState = "completed" | "unresolved" | "current" | "active" | "blocked"; +import { buildToolPairs, type ClosureEvent } from "./tool-closure.js"; -export type ItemKind = "message" | "tool_call" | "tool_result" | "compaction_checkpoint" | string; +export type TaskState = SurfaceTaskState; -export interface SafetyItem { - sourceEventSeq: number; - kind: ItemKind; - taskState: TaskState; - current: boolean; - /** Present for tool_call / tool_result items; ties the item to its pair. */ - callIds?: readonly string[]; - /** Visible characters that would actually be replaced. */ - chars?: number; -} +export type ItemKind = SurfaceItemKind; + +export type SafetyItem = SurfaceSafetyItem; -export type SafetyAction = "evict" | "keep"; +export type SafetyAction = SurfaceSafetyAction; export interface SafetyDecision { action: Map; @@ -43,18 +42,11 @@ export interface SafetyDecision { deferredCallIds: string[]; } -/** True when an item is individually clearable (ignoring tool-group coupling). */ -function isClearable(item: SafetyItem, effective: Set): boolean { - if (!effective.has(item.sourceEventSeq)) return false; - if (item.current) return false; - if (item.kind === "compaction_checkpoint") return false; - return item.taskState === "completed"; -} - /** * Apply the safety policy. `events` is only needed to re-derive tool pairs over * the effective surface; item task state comes from the caller (the estimator / - * registry), never re-inferred here. + * registry), never re-inferred here. The policy itself is the shared + * canonical-surface policy in `@lightrsi/eviction`. */ export function applySafetyPolicy( items: readonly SafetyItem[], @@ -62,69 +54,12 @@ export function applySafetyPolicy( events: readonly ClosureEvent[], minBlockChars = 0, ): SafetyDecision { - const effective = new Set(effectiveSeqs); - const action = new Map(); - for (const item of items) action.set(item.sourceEventSeq, "keep"); // default keep - + const effective = [...effectiveSeqs]; const pairs = buildToolPairs(events, effective); - const itemsBySeq = new Map(items.map((it) => [it.sourceEventSeq, it])); - const deferredCallIds: string[] = []; - - // DSH keeps tool calls inside assistant messages. Preserve that call envelope - // and only rewrite a result when its pair is strict and both sides are safe. - const tooledSeqs = new Set(); - const unsafeToolSeqs = new Set(); - const unsafeCallEnvelopeSeqs = new Set(); - for (const pair of pairs.values()) { - const memberSeqs = [...pair.callSeqs, ...pair.resultSeqs]; - for (const seq of memberSeqs) tooledSeqs.add(seq); - - const everyMemberClearable = memberSeqs.every((seq) => { - const it = itemsBySeq.get(seq); - return it ? isClearable(it, effective) : false; - }); - - if (!isEvictablePair(pair.status) || !everyMemberClearable) { - for (const seq of memberSeqs) unsafeToolSeqs.add(seq); - for (const seq of pair.callSeqs) unsafeCallEnvelopeSeqs.add(seq); - deferredCallIds.push(pair.callId); - } - } - - // One assistant message may carry multiple calls. If any call in that shared - // envelope is unsafe, no sibling result may be rewritten independently. - for (const pair of pairs.values()) { - if (!pair.callSeqs.some((seq) => unsafeCallEnvelopeSeqs.has(seq))) continue; - for (const seq of [...pair.callSeqs, ...pair.resultSeqs]) unsafeToolSeqs.add(seq); - deferredCallIds.push(pair.callId); - } - - for (const item of items) { - if (!tooledSeqs.has(item.sourceEventSeq)) continue; - const resultIsLargeEnough = item.kind === "tool_result" && (item.chars ?? 0) >= minBlockChars; - action.set( - item.sourceEventSeq, - resultIsLargeEnough && !unsafeToolSeqs.has(item.sourceEventSeq) ? "evict" : "keep", - ); - } - - // Non-tool items: evict iff individually clearable. - for (const item of items) { - if (tooledSeqs.has(item.sourceEventSeq)) continue; - action.set( - item.sourceEventSeq, - isClearable(item, effective) && (item.chars ?? 0) >= minBlockChars ? "evict" : "keep", - ); - } - - const evictSeqs: number[] = []; - const keepSeqs: number[] = []; - for (const [seq, act] of action) (act === "evict" ? evictSeqs : keepSeqs).push(seq); - evictSeqs.sort((a, b) => a - b); - keepSeqs.sort((a, b) => a - b); - const uniqueDeferredCallIds = [...new Set(deferredCallIds)].sort(); - - return { action, evictSeqs, keepSeqs, deferredCallIds: uniqueDeferredCallIds }; + const decision = applySurfaceSafetyPolicy(items, effective, pairs, minBlockChars); + decision.evictSeqs.sort((a, b) => a - b); + decision.keepSeqs.sort((a, b) => a - b); + return decision; } /** diff --git a/components/adapters/deepseek-harness/src/tool-closure.ts b/components/adapters/deepseek-harness/src/tool-closure.ts index 2dc67f8b..329a4021 100644 --- a/components/adapters/deepseek-harness/src/tool-closure.ts +++ b/components/adapters/deepseek-harness/src/tool-closure.ts @@ -9,21 +9,22 @@ * This mirrors the pairing scheme the shared fixture oracle uses * (tests/session-event-fixtures.test.ts): tool/call → data.callId; tool/result * → message.source.callId (fallback: the tool-result block's toolCallId). + * + * Pair classification is the shared canonical-surface closure in + * `@lightrsi/eviction`; only DSH event parsing lives here. */ -export type ToolPairStatus = - | "closed" - | "orphan_result" - | "missing_result" - | "duplicate_call" - | "duplicate_result"; +import { + assembleToolPairs, + classifyToolPair, + isEvictableToolPair, + type ToolPair as SharedToolPair, + type ToolPairStatus as SharedToolPairStatus, +} from "@lightrsi/eviction"; -export interface ToolPair { - callId: string; - callSeqs: number[]; - resultSeqs: number[]; - status: ToolPairStatus; -} +export type ToolPairStatus = SharedToolPairStatus; + +export type ToolPair = SharedToolPair; /** Minimal event shape the closure logic reads. */ export interface ClosureEvent { @@ -68,16 +69,12 @@ export function assistantCallIds(event: ClosureEvent): string[] { } export function classifyPair(callSeqs: readonly number[], resultSeqs: readonly number[]): ToolPairStatus { - if (callSeqs.length > 1) return "duplicate_call"; - if (resultSeqs.length > 1) return "duplicate_result"; - if (callSeqs.length === 0) return "orphan_result"; - if (resultSeqs.length === 0) return "missing_result"; - return "closed"; + return classifyToolPair(callSeqs, resultSeqs); } -/** Only a strictly closed pair may be evicted (and only as a unit). */ +/** Only a strict closed pair may be evicted (and only as a unit). */ export function isEvictablePair(status: ToolPairStatus): boolean { - return status === "closed"; + return isEvictableToolPair(status); } /** @@ -105,11 +102,5 @@ export function buildToolPairs( } } - const pairs = new Map(); - for (const callId of new Set([...calls.keys(), ...results.keys()])) { - const callSeqs = (calls.get(callId) ?? []).sort((a, b) => a - b); - const resultSeqs = (results.get(callId) ?? []).sort((a, b) => a - b); - pairs.set(callId, { callId, callSeqs, resultSeqs, status: classifyPair(callSeqs, resultSeqs) }); - } - return pairs; + return assembleToolPairs(calls, results, (a, b) => a - b); } diff --git a/components/packages/features/eviction/src/canonical-surface/estimator-config.ts b/components/packages/features/eviction/src/canonical-surface/estimator-config.ts new file mode 100644 index 00000000..f7df7f3a --- /dev/null +++ b/components/packages/features/eviction/src/canonical-surface/estimator-config.ts @@ -0,0 +1,25 @@ +/** + * Shared task-state estimator wiring for canonical-surface eviction hosts. + * + * Lifted from the DeepSeek Harness adapter (`lifecycle-estimator.ts`). The + * estimator is constructed only when it has an endpoint, credentials and a + * model, so a missing setting defers eviction instead of failing a request. + */ + +import { createApiTaskStateEstimator } from "../task-state-estimator.js"; +import type { TaskStateEstimator, TaskStateEstimatorApiConfig } from "../types.js"; + +/** True only when the estimator has the endpoint + credentials it needs to run. */ +export function isTaskStateEstimatorConfigured(cfg: Pick): boolean { + return Boolean(cfg.baseUrl && cfg.apiKey && cfg.model); +} + +/** + * Construct the shared estimator, or return `undefined` when it is disabled or + * incompletely configured. Returning `undefined` lets hosts fail open. + */ +export function createConfiguredTaskStateEstimator(cfg: TaskStateEstimatorApiConfig): TaskStateEstimator | undefined { + if (cfg.enabled === false) return undefined; + if (!isTaskStateEstimatorConfigured(cfg)) return undefined; + return createApiTaskStateEstimator(cfg); +} diff --git a/components/packages/features/eviction/src/canonical-surface/eviction-cycle.ts b/components/packages/features/eviction/src/canonical-surface/eviction-cycle.ts new file mode 100644 index 00000000..e8e6d857 --- /dev/null +++ b/components/packages/features/eviction/src/canonical-surface/eviction-cycle.ts @@ -0,0 +1,169 @@ +/** + * Host-neutral canonical-surface eviction cycle. + * + * host snapshot → shared estimator → registry update → safety filter → host transaction + * + * It is a pure pipeline over an in-hand registry: the registry comes in as a + * parameter and the updated registry comes back out. Loading and persisting it, + * building the snapshot and surface items, and applying the transaction stay + * with the host adapter. + * + * Lifted from the DeepSeek Harness adapter (`eviction-cycle.ts`, `runDshEvictionCycle`) + * without behaviour change. The DSH-specific parts (event parsing, native + * replacement envelopes, `surfaceOp` appends) remain in that adapter. + */ + +import { + applySessionTaskRegistryPatch, + buildDeltaViewFromRawSemanticSnapshot, + type RawSemanticSnapshot, + type SessionTaskRegistry, +} from "@lightrsi/history"; +import type { TaskStateEstimator } from "../types.js"; +import { mapTaskUpdatesToRegistryPatch } from "../task-update-mapper.js"; +import { + applySurfaceSafetyPolicy, + classifySurfaceItemTaskState, + type SurfaceItemKind, + type SurfaceSafetyDecision, + type SurfaceSafetyItem, +} from "./safety-policy.js"; +import type { ToolPair } from "./tool-closure.js"; + +/** A model-visible item on the host's current surface. */ +export interface SurfaceCycleItem { + sourceEventSeq: TId; + /** Turn number used for `${sessionId}:t${turn}` registry lookups. */ + turn: number; + kind: SurfaceItemKind; + callIds?: readonly string[]; + chars: number; +} + +/** The only field of a host transaction result the cycle reads. */ +export interface SurfaceTransactionOutcome { + status: "committed" | "partial" | "deferred" | "empty"; +} + +export interface CanonicalEvictionCycleResult { + registry: SessionTaskRegistry; + result: TResult; + registryPersisted: boolean; + /** Coarse status for logging: why nothing was applied, when applicable. */ + status: "applied" | "deferred" | "empty" | "no-delta"; + decision?: SurfaceSafetyDecision; +} + +export async function runCanonicalEvictionCycle(params: { + snapshot: RawSemanticSnapshot; + registry: SessionTaskRegistry; + estimator: TaskStateEstimator; + items: readonly SurfaceCycleItem[]; + effectiveSeqs: readonly TId[]; + pairs: ReadonlyMap>; + currentTurn: number; + minBlockChars?: number; + /** Track task state without modifying the canonical model-visible surface. */ + allowSurfaceMutation?: boolean; + /** Orders the evict list handed to `apply` (defaults to item order). */ + compareSeqs?: (left: TId, right: TId) => number; + persistRegistry?: (registry: SessionTaskRegistry, expectedVersion: number) => void | Promise; + /** Host transaction: apply the safe evict set. Must be all-or-report (no silent partials). */ + apply(evictSeqs: TId[]): TResult | Promise; + emptyResult(): TResult; +}): Promise> { + const delta = buildDeltaViewFromRawSemanticSnapshot(params.snapshot, { + fromTurnSeqExclusive: params.registry.lastProcessedTurnSeq, + }); + let registry = params.registry; + let registryPersistedBeforeMutation = false; + let registryExpectedVersion = params.registry.version; + if (delta.coveredTurnAbsIds.length > 0) { + const output = await params.estimator.estimate({ registry: params.registry, delta }); + const { patch } = mapTaskUpdatesToRegistryPatch({ + registry: params.registry, + updates: output.taskUpdates, + coveredTurnAbsIds: delta.coveredTurnAbsIds, + toTurnSeqInclusive: delta.toTurnSeqInclusive, + }); + registry = applySessionTaskRegistryPatch(params.registry, patch); + // Registry CAS is a precondition for canonical mutation. Persist task state + // first, but keep the watermark behind until the surface transaction lands; + // otherwise a partial apply would permanently hide its unprocessed tail. + const pendingRegistry = { + ...registry, + lastProcessedTurnSeq: params.registry.lastProcessedTurnSeq, + }; + await params.persistRegistry?.(pendingRegistry, params.registry.version); + registry = pendingRegistry; + registryPersistedBeforeMutation = params.persistRegistry !== undefined; + registryExpectedVersion = pendingRegistry.version; + } + + const safetyItems: SurfaceSafetyItem[] = params.items.map((it) => { + const c = classifySurfaceItemTaskState(it.turn, registry, params.currentTurn); + return { + sourceEventSeq: it.sourceEventSeq, + kind: it.kind, + taskState: c.taskState, + current: c.current, + callIds: it.callIds, + chars: it.chars, + }; + }); + + const decision = applySurfaceSafetyPolicy( + safetyItems, + params.effectiveSeqs, + params.pairs, + params.minBlockChars ?? 0, + ); + if (params.compareSeqs) { + decision.evictSeqs.sort(params.compareSeqs); + decision.keepSeqs.sort(params.compareSeqs); + } + + const advance = async (status: "empty" | "no-delta"): Promise> => { + registry = { ...registry, lastProcessedTurnSeq: delta.toTurnSeqInclusive }; + if (registryPersistedBeforeMutation && params.persistRegistry) { + await params.persistRegistry(registry, registryExpectedVersion); + } + return { registry, result: params.emptyResult(), registryPersisted: true, status, decision }; + }; + + if (decision.evictSeqs.length === 0) { + return advance(delta.coveredTurnAbsIds.length === 0 ? "no-delta" : "empty"); + } + + // Context Cleaner needs the same completed-task registry as automatic + // eviction, but a Cleaner-only profile must never remove context before the + // user makes an explicit selection. + if (params.allowSurfaceMutation === false) { + return advance("empty"); + } + + const result = await params.apply([...decision.evictSeqs]); + + const shouldAdvanceWatermark = result.status === "committed"; + const finalRegistry = shouldAdvanceWatermark + ? { ...registry, lastProcessedTurnSeq: delta.toTurnSeqInclusive } + : registry; + let registryPersisted = true; + if (shouldAdvanceWatermark && registryPersistedBeforeMutation && params.persistRegistry) { + try { + await params.persistRegistry(finalRegistry, registryExpectedVersion); + } catch { + // Replacements already landed. Keep the in-memory result truthful and let + // the next cycle recover the stale watermark from the canonical surface. + registryPersisted = false; + } + } + + return { + registry: finalRegistry, + result, + registryPersisted, + status: result.status === "committed" || result.status === "partial" ? "applied" : "deferred", + decision, + }; +} diff --git a/components/packages/features/eviction/src/canonical-surface/index.ts b/components/packages/features/eviction/src/canonical-surface/index.ts new file mode 100644 index 00000000..4083b75b --- /dev/null +++ b/components/packages/features/eviction/src/canonical-surface/index.ts @@ -0,0 +1,4 @@ +export * from "./tool-closure.js"; +export * from "./safety-policy.js"; +export * from "./eviction-cycle.js"; +export * from "./estimator-config.js"; diff --git a/components/packages/features/eviction/src/canonical-surface/safety-policy.ts b/components/packages/features/eviction/src/canonical-surface/safety-policy.ts new file mode 100644 index 00000000..259abc53 --- /dev/null +++ b/components/packages/features/eviction/src/canonical-surface/safety-policy.ts @@ -0,0 +1,148 @@ +/** + * Independent canonical-surface eviction safety policy. + * + * The estimator only proposes candidates (via task lifecycle). This filter runs + * independently before any surface mutation and never trusts the model's + * self-reported state. It clears an item for eviction only if all hold: + * - the item is on the current surface (effective), + * - its task is `completed` (not current / active / blocked / unresolved), + * - it is not part of the current turn, + * - it is not a canonical compaction checkpoint, + * - if it is a tool call/result, its call group is strictly closed + * (1 call + 1 result) and every item in that group is itself clearable; + * otherwise the whole group is deferred. + * + * Lifted from the DeepSeek Harness adapter (`safety-policy.ts`) without + * behaviour change. Tool pairs are supplied by the host instead of being derived + * from DSH events here. + */ + +import type { SessionTaskRegistry } from "@lightrsi/history"; +import { isEvictableToolPair, type ToolPair } from "./tool-closure.js"; + +export type SurfaceTaskState = "completed" | "unresolved" | "current" | "active" | "blocked"; + +export type SurfaceItemKind = "message" | "tool_call" | "tool_result" | "compaction_checkpoint" | string; + +export interface SurfaceSafetyItem { + sourceEventSeq: TId; + kind: SurfaceItemKind; + taskState: SurfaceTaskState; + current: boolean; + /** Present for tool_call / tool_result items; ties the item to its pair. */ + callIds?: readonly string[]; + /** Visible characters that would actually be replaced. */ + chars?: number; +} + +export type SurfaceSafetyAction = "evict" | "keep"; + +export interface SurfaceSafetyDecision { + action: Map; + /** In item order (callers may re-sort, e.g. DSH sorts by numeric seq). */ + evictSeqs: TId[]; + keepSeqs: TId[]; + /** callIds whose group was protected because it wasn't strictly closed. */ + deferredCallIds: string[]; +} + +/** True when an item is individually clearable (ignoring tool-group coupling). */ +function isClearable(item: SurfaceSafetyItem, effective: Set): boolean { + if (!effective.has(item.sourceEventSeq)) return false; + if (item.current) return false; + if (item.kind === "compaction_checkpoint") return false; + return item.taskState === "completed"; +} + +export function applySurfaceSafetyPolicy( + items: readonly SurfaceSafetyItem[], + effectiveSeqs: Iterable, + pairs: ReadonlyMap>, + minBlockChars = 0, +): SurfaceSafetyDecision { + const effective = new Set(effectiveSeqs); + const action = new Map(); + for (const item of items) action.set(item.sourceEventSeq, "keep"); // default keep + + const itemsBySeq = new Map(items.map((it) => [it.sourceEventSeq, it])); + const deferredCallIds: string[] = []; + + // Tool calls live inside assistant envelopes. Preserve that envelope and only + // rewrite a result when its pair is strict and both sides are safe. + const tooledSeqs = new Set(); + const unsafeToolSeqs = new Set(); + const unsafeCallEnvelopeSeqs = new Set(); + for (const pair of pairs.values()) { + const memberSeqs = [...pair.callSeqs, ...pair.resultSeqs]; + for (const seq of memberSeqs) tooledSeqs.add(seq); + + const everyMemberClearable = memberSeqs.every((seq) => { + const it = itemsBySeq.get(seq); + return it ? isClearable(it, effective) : false; + }); + + if (!isEvictableToolPair(pair.status) || !everyMemberClearable) { + for (const seq of memberSeqs) unsafeToolSeqs.add(seq); + for (const seq of pair.callSeqs) unsafeCallEnvelopeSeqs.add(seq); + deferredCallIds.push(pair.callId); + } + } + + // One assistant message may carry multiple calls. If any call in that shared + // envelope is unsafe, no sibling result may be rewritten independently. + for (const pair of pairs.values()) { + if (!pair.callSeqs.some((seq) => unsafeCallEnvelopeSeqs.has(seq))) continue; + for (const seq of [...pair.callSeqs, ...pair.resultSeqs]) unsafeToolSeqs.add(seq); + deferredCallIds.push(pair.callId); + } + + for (const item of items) { + if (!tooledSeqs.has(item.sourceEventSeq)) continue; + const resultIsLargeEnough = item.kind === "tool_result" && (item.chars ?? 0) >= minBlockChars; + action.set( + item.sourceEventSeq, + resultIsLargeEnough && !unsafeToolSeqs.has(item.sourceEventSeq) ? "evict" : "keep", + ); + } + + // Non-tool items: evict iff individually clearable. + for (const item of items) { + if (tooledSeqs.has(item.sourceEventSeq)) continue; + action.set( + item.sourceEventSeq, + isClearable(item, effective) && (item.chars ?? 0) >= minBlockChars ? "evict" : "keep", + ); + } + + const evictSeqs: TId[] = []; + const keepSeqs: TId[] = []; + for (const [seq, act] of action) (act === "evict" ? evictSeqs : keepSeqs).push(seq); + const uniqueDeferredCallIds = [...new Set(deferredCallIds)].sort(); + + return { action, evictSeqs, keepSeqs, deferredCallIds: uniqueDeferredCallIds }; +} + +/** + * Classify a surface item's task state from the registry (never re-inferred from + * content). Items at or after `currentTurn` are current. An item is completed + * only if every owning task is completed/evictable and none is active. + */ +export function classifySurfaceItemTaskState( + turn: number, + registry: SessionTaskRegistry, + currentTurn: number, +): { taskState: SurfaceTaskState; current: boolean } { + if (turn >= currentTurn) return { taskState: "current", current: true }; + + const turnAbsId = `${registry.sessionId}:t${turn}`; + const taskIds = registry.turnToTaskIds[turnAbsId] ?? []; + const evictable = new Set(registry.evictableTaskIds); + const completed = new Set(registry.completedTaskIds); + const active = new Set(registry.activeTaskIds); + + if (taskIds.length > 0 && taskIds.every((id) => evictable.has(id) || completed.has(id)) && !taskIds.some((id) => active.has(id))) { + return { taskState: "completed", current: false }; + } + if (taskIds.some((id) => active.has(id))) return { taskState: "active", current: false }; + return { taskState: "unresolved", current: false }; +} diff --git a/components/packages/features/eviction/src/canonical-surface/tool-closure.ts b/components/packages/features/eviction/src/canonical-surface/tool-closure.ts new file mode 100644 index 00000000..5dcc8746 --- /dev/null +++ b/components/packages/features/eviction/src/canonical-surface/tool-closure.ts @@ -0,0 +1,92 @@ +/** + * Host-neutral tool call/result closure for canonical-surface eviction. + * + * A tool protocol group is keyed by call id. Only a strict one-call/one-result + * pair may be rewritten, and only as a unit. Hosts supply the call and result + * memberships (DSH derives them from its durable events; transcript-based hosts + * derive them from surface items), so this module never parses host shapes. + * + * Lifted from the DeepSeek Harness adapter (`tool-closure.ts`) without + * behaviour change. + */ + +export type ToolPairStatus = + | "closed" + | "orphan_result" + | "missing_result" + | "duplicate_call" + | "duplicate_result"; + +export interface ToolPair { + callId: string; + callSeqs: TId[]; + resultSeqs: TId[]; + status: ToolPairStatus; +} + +export function classifyToolPair( + callSeqs: readonly unknown[], + resultSeqs: readonly unknown[], +): ToolPairStatus { + if (callSeqs.length > 1) return "duplicate_call"; + if (resultSeqs.length > 1) return "duplicate_result"; + if (callSeqs.length === 0) return "orphan_result"; + if (resultSeqs.length === 0) return "missing_result"; + return "closed"; +} + +/** Only a strictly closed pair may be evicted (and only as a unit). */ +export function isEvictableToolPair(status: ToolPairStatus): boolean { + return status === "closed"; +} + +/** + * Assemble pairs from per-call memberships. `compare` orders member ids within a + * pair (DSH: numeric seq order; transcript hosts: surface order). + */ +export function assembleToolPairs( + calls: ReadonlyMap, + results: ReadonlyMap, + compare: (left: TId, right: TId) => number, +): Map> { + const pairs = new Map>(); + for (const callId of new Set([...calls.keys(), ...results.keys()])) { + const callSeqs = [...(calls.get(callId) ?? [])].sort(compare); + const resultSeqs = [...(results.get(callId) ?? [])].sort(compare); + pairs.set(callId, { callId, callSeqs, resultSeqs, status: classifyToolPair(callSeqs, resultSeqs) }); + } + return pairs; +} + +/** Minimal item view used to derive pairs directly from surface items. */ +export interface ToolPairSourceItem { + sourceEventSeq: TId; + kind: string; + callIds?: readonly string[]; +} + +/** + * Derive pairs from surface items: `tool_call` items carry the call ids of their + * assistant envelope, `tool_result` items carry their single result call id. + * Items not in `effectiveSeqs` never participate. + */ +export function buildToolPairsFromItems( + items: readonly ToolPairSourceItem[], + effectiveSeqs: Iterable, +): Map> { + const effective = new Set(effectiveSeqs); + const order = new Map(); + const calls = new Map(); + const results = new Map(); + items.forEach((item, index) => { + order.set(item.sourceEventSeq, index); + if (!effective.has(item.sourceEventSeq)) return; + const target = item.kind === "tool_call" ? calls : item.kind === "tool_result" ? results : undefined; + if (!target) return; + for (const id of item.callIds ?? []) { + if (!id) continue; + (target.get(id) ?? target.set(id, []).get(id)!).push(item.sourceEventSeq); + } + }); + return assembleToolPairs(calls, results, (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0)); +} diff --git a/components/packages/features/eviction/src/index.ts b/components/packages/features/eviction/src/index.ts index 9d344c92..b7a43f26 100644 --- a/components/packages/features/eviction/src/index.ts +++ b/components/packages/features/eviction/src/index.ts @@ -9,3 +9,4 @@ export * from "./history-apply.js"; export * from "./lifecycle-policy-context.js"; export * from "./lifecycle-planner.js"; export * from "./context-mutation-plan.js"; +export * from "./canonical-surface/index.js"; diff --git a/components/packages/features/eviction/tests/canonical-surface.test.ts b/components/packages/features/eviction/tests/canonical-surface.test.ts new file mode 100644 index 00000000..4969eb3d --- /dev/null +++ b/components/packages/features/eviction/tests/canonical-surface.test.ts @@ -0,0 +1,197 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + createEmptySessionTaskRegistry, + type RawSemanticSnapshot, + type SessionTaskRegistry, +} from "@lightrsi/history"; + +import { + applySurfaceSafetyPolicy, + buildToolPairsFromItems, + classifySurfaceItemTaskState, + createConfiguredTaskStateEstimator, + isTaskStateEstimatorConfigured, + runCanonicalEvictionCycle, + type SemanticTaskUpdate, + type SurfaceCycleItem, + type SurfaceSafetyItem, + type SurfaceTransactionOutcome, +} from "../src/index.js"; + +const SESSION = "s1"; + +/** Two turns: t1 is a finished task with one tool pair, t2 is current. */ +function items(): SurfaceCycleItem[] { + return [ + { sourceEventSeq: "u1", turn: 1, kind: "message", chars: 40 }, + { sourceEventSeq: "a1", turn: 1, kind: "tool_call", callIds: ["c1"], chars: 20 }, + { sourceEventSeq: "r1", turn: 1, kind: "tool_result", callIds: ["c1"], chars: 5000 }, + { sourceEventSeq: "a1b", turn: 1, kind: "message", chars: 300 }, + { sourceEventSeq: "u2", turn: 2, kind: "message", chars: 30 }, + ]; +} + +function snapshot(): RawSemanticSnapshot { + const anchor = (turnSeq: number, role: "user" | "assistant" | "tool") => ({ + sessionId: SESSION, turnAbsId: `${SESSION}:t${turnSeq}`, turnSeq, role, + }); + return { + sessionId: SESSION, + lastTurnSeq: 2, + messages: [ + { anchor: anchor(1, "user"), role: "user", text: "read the config" }, + { anchor: anchor(1, "assistant"), role: "assistant", text: "done, config says x" }, + { anchor: anchor(2, "user"), role: "user", text: "now something else" }, + ], + toolCalls: [{ anchor: anchor(1, "assistant"), toolCallId: "c1", toolName: "read", argumentsSummary: "{}" }], + toolResults: [{ + anchor: anchor(1, "tool"), toolCallId: "c1", toolName: "read", status: "success", fullText: "x".repeat(5000), summary: "x", + }], + }; +} + +function completedT1Estimator() { + const taskUpdates: SemanticTaskUpdate[] = [{ + taskId: "task-config", + objective: "read the config", + lifecycle: "completed", + coveredTurnAbsIds: [`${SESSION}:t1`], + completionEvidence: ["assistant reported config value"], + }]; + return { estimate: () => ({ baseVersion: 0, taskUpdates }) }; +} + +describe("canonical-surface tool closure", () => { + it("pairs string-id items and flags unclosed groups", () => { + const pairs = buildToolPairsFromItems([ + { sourceEventSeq: "a", kind: "tool_call", callIds: ["c1", "c2"] }, + { sourceEventSeq: "b", kind: "tool_result", callIds: ["c1"] }, + { sourceEventSeq: "c", kind: "tool_result", callIds: ["c3"] }, + ], ["a", "b", "c"]); + assert.equal(pairs.get("c1")?.status, "closed"); + assert.equal(pairs.get("c2")?.status, "missing_result"); + assert.equal(pairs.get("c3")?.status, "orphan_result"); + }); + + it("ignores items that are not on the effective surface", () => { + const pairs = buildToolPairsFromItems([ + { sourceEventSeq: "a", kind: "tool_call", callIds: ["c1"] }, + { sourceEventSeq: "b", kind: "tool_result", callIds: ["c1"] }, + ], ["b"]); + assert.equal(pairs.get("c1")?.status, "orphan_result"); + }); +}); + +describe("canonical-surface safety policy", () => { + it("evicts only a closed, completed tool result above minBlockChars and never the call envelope", () => { + const safety: SurfaceSafetyItem[] = [ + { sourceEventSeq: "a1", kind: "tool_call", callIds: ["c1"], taskState: "completed", current: false, chars: 20 }, + { sourceEventSeq: "r1", kind: "tool_result", callIds: ["c1"], taskState: "completed", current: false, chars: 5000 }, + { sourceEventSeq: "u2", kind: "message", taskState: "current", current: true, chars: 30 }, + ]; + const effective = ["a1", "r1", "u2"]; + const decision = applySurfaceSafetyPolicy(safety, effective, buildToolPairsFromItems(safety, effective), 1000); + assert.deepEqual(decision.evictSeqs, ["r1"]); + assert.deepEqual(decision.keepSeqs.sort(), ["a1", "u2"]); + }); + + it("defers every sibling when a shared assistant envelope has an unsafe call", () => { + const safety: SurfaceSafetyItem[] = [ + { sourceEventSeq: "a1", kind: "tool_call", callIds: ["c1", "c2"], taskState: "completed", current: false, chars: 20 }, + { sourceEventSeq: "r1", kind: "tool_result", callIds: ["c1"], taskState: "completed", current: false, chars: 5000 }, + ]; + const decision = applySurfaceSafetyPolicy(safety, ["a1", "r1"], buildToolPairsFromItems(safety, ["a1", "r1"])); + assert.deepEqual(decision.evictSeqs, []); + assert.deepEqual(decision.deferredCallIds, ["c1", "c2"]); + }); + + it("classifies task state from the registry only", () => { + const registry: SessionTaskRegistry = { + ...createEmptySessionTaskRegistry(SESSION), + completedTaskIds: ["t"], + turnToTaskIds: { [`${SESSION}:t1`]: ["t"] }, + }; + assert.deepEqual(classifySurfaceItemTaskState(1, registry, 2), { taskState: "completed", current: false }); + assert.deepEqual(classifySurfaceItemTaskState(2, registry, 2), { taskState: "current", current: true }); + assert.deepEqual(classifySurfaceItemTaskState(0, registry, 2), { taskState: "unresolved", current: false }); + }); +}); + +describe("canonical-surface eviction cycle", () => { + it("estimates, persists, applies the safe set, and advances the watermark", async () => { + const persisted: SessionTaskRegistry[] = []; + let applied: string[] = []; + const list = items(); + const cycle = await runCanonicalEvictionCycle({ + snapshot: snapshot(), + registry: createEmptySessionTaskRegistry(SESSION), + estimator: completedT1Estimator(), + items: list, + effectiveSeqs: list.map((it) => it.sourceEventSeq), + pairs: buildToolPairsFromItems(list, list.map((it) => it.sourceEventSeq)), + currentTurn: 2, + minBlockChars: 256, + persistRegistry: (registry) => { persisted.push(registry); }, + emptyResult: (): SurfaceTransactionOutcome => ({ status: "empty" }), + apply(evictSeqs) { + applied = evictSeqs; + return { status: "committed" as const }; + }, + }); + assert.equal(cycle.status, "applied"); + assert.deepEqual(applied, ["r1", "a1b"]); + assert.equal(cycle.registry.lastProcessedTurnSeq, 2); + // Task state lands first with the old watermark, then the watermark advances. + assert.equal(persisted[0]?.lastProcessedTurnSeq, 0); + assert.equal(persisted.at(-1)?.lastProcessedTurnSeq, 2); + }); + + it("tracks task state without mutating when surface mutation is not allowed", async () => { + let called = false; + const list = items(); + const cycle = await runCanonicalEvictionCycle({ + snapshot: snapshot(), + registry: createEmptySessionTaskRegistry(SESSION), + estimator: completedT1Estimator(), + items: list, + effectiveSeqs: list.map((it) => it.sourceEventSeq), + pairs: buildToolPairsFromItems(list, list.map((it) => it.sourceEventSeq)), + currentTurn: 2, + allowSurfaceMutation: false, + emptyResult: (): SurfaceTransactionOutcome => ({ status: "empty" }), + apply() { called = true; return { status: "committed" as const }; }, + }); + assert.equal(called, false); + assert.equal(cycle.status, "empty"); + assert.deepEqual(cycle.registry.completedTaskIds, ["task-config"]); + }); + + it("keeps the watermark behind when the host transaction defers", async () => { + const list = items(); + const cycle = await runCanonicalEvictionCycle({ + snapshot: snapshot(), + registry: createEmptySessionTaskRegistry(SESSION), + estimator: completedT1Estimator(), + items: list, + effectiveSeqs: list.map((it) => it.sourceEventSeq), + pairs: buildToolPairsFromItems(list, list.map((it) => it.sourceEventSeq)), + currentTurn: 2, + emptyResult: (): SurfaceTransactionOutcome => ({ status: "empty" }), + apply: () => ({ status: "deferred" as const }), + }); + assert.equal(cycle.status, "deferred"); + assert.equal(cycle.registry.lastProcessedTurnSeq, 0); + }); +}); + +describe("canonical-surface estimator config", () => { + it("requires baseUrl, apiKey and model", () => { + assert.equal(isTaskStateEstimatorConfigured({ baseUrl: "http://x", apiKey: "k" }), false); + assert.equal(isTaskStateEstimatorConfigured({ baseUrl: "http://x", apiKey: "k", model: "m" }), true); + assert.equal(createConfiguredTaskStateEstimator({ baseUrl: "http://x", apiKey: "k" }), undefined); + assert.equal(createConfiguredTaskStateEstimator({ enabled: false, baseUrl: "http://x", apiKey: "k", model: "m" }), undefined); + assert.ok(createConfiguredTaskStateEstimator({ baseUrl: "http://x", apiKey: "k", model: "m" })); + }); +}); From 93b90675cc827b12dcc22539e896f5a31e2bd1d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:46:01 +0000 Subject: [PATCH 2/2] test(eviction): case-by-case regression tests for the shared canonical-surface core Replace the initial smoke tests with one test file per module (tool closure, safety policy, eviction cycle, estimator config). Each file lists the input cases it covers at the top and has exactly one test per case, so a failure names the behaviour that changed. Pin the DeepSeek Harness wrappers to the shared core with a delegation test file: tool pairs, safety decisions (per oracle fixture and min-size boundary), pair classification, estimator construction, and the pre-refactor export surface. eviction: 119/119, deepseek-harness: 138/138. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S6it2Si8k8XNGMF8RdyMQz --- .../tests/shared-eviction-delegation.test.ts | 160 ++++++++++++ ...canonical-surface-estimator-config.test.ts | 51 ++++ .../canonical-surface-eviction-cycle.test.ts | 231 ++++++++++++++++++ .../canonical-surface-safety-policy.test.ts | 191 +++++++++++++++ .../canonical-surface-tool-closure.test.ts | 138 +++++++++++ .../eviction/tests/canonical-surface.test.ts | 197 --------------- 6 files changed, 771 insertions(+), 197 deletions(-) create mode 100644 components/adapters/deepseek-harness/tests/shared-eviction-delegation.test.ts create mode 100644 components/packages/features/eviction/tests/canonical-surface-estimator-config.test.ts create mode 100644 components/packages/features/eviction/tests/canonical-surface-eviction-cycle.test.ts create mode 100644 components/packages/features/eviction/tests/canonical-surface-safety-policy.test.ts create mode 100644 components/packages/features/eviction/tests/canonical-surface-tool-closure.test.ts delete mode 100644 components/packages/features/eviction/tests/canonical-surface.test.ts diff --git a/components/adapters/deepseek-harness/tests/shared-eviction-delegation.test.ts b/components/adapters/deepseek-harness/tests/shared-eviction-delegation.test.ts new file mode 100644 index 00000000..c0f7a8ef --- /dev/null +++ b/components/adapters/deepseek-harness/tests/shared-eviction-delegation.test.ts @@ -0,0 +1,160 @@ +/** + * Regression matrix: DSH wrappers over the shared canonical-surface eviction core + * (refactor that moved tool-closure / safety-policy / cycle into @lightrsi/eviction). + * + * W1 buildToolPairs (DSH events) equals buildToolPairsFromItems over + * describeEffectiveItems for every oracle fixture case + * W2 applySafetyPolicy (DSH) equals applySurfaceSafetyPolicy fed with DSH pairs, + * for every fixture case, with evict/keep sorted by numeric seq + * W3 classifyPair / isEvictablePair delegate to the shared classifier for every + * (calls, results) partition + * W4 isEstimatorConfigured / createDshTaskStateEstimator agree with the shared + * estimator helpers for every presence combination of baseUrl/apiKey/model + * and for enabled=false + * W5 DSH still exports the pre-refactor public names (API compatibility) + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { + applySurfaceSafetyPolicy, + buildToolPairsFromItems, + classifyToolPair, + createConfiguredTaskStateEstimator, + isEvictableToolPair, + isTaskStateEstimatorConfigured, + type ToolPairStatus, +} from "@lightrsi/eviction"; + +import { describeEffectiveItems } from "../src/eviction-cycle.js"; +import { createDshTaskStateEstimator, isEstimatorConfigured } from "../src/lifecycle-estimator.js"; +import { applySafetyPolicy, type SafetyItem, type TaskState } from "../src/safety-policy.js"; +import { buildToolPairs, classifyPair, isEvictablePair } from "../src/tool-closure.js"; +import * as cycleModule from "../src/eviction-cycle.js"; +import * as closureModule from "../src/tool-closure.js"; +import * as safetyModule from "../src/safety-policy.js"; +import type { DshEstimatorConfig } from "../src/config.js"; +import type { DshLogEventWithMeta } from "../src/types.js"; + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/session-events.json", import.meta.url), "utf8"), +) as { + cases: Array<{ + id: string; + events: DshLogEventWithMeta[]; + effectiveEventSeqs: number[]; + expected: { items: Array<{ sourceEventSeq: number; kind: string; taskState: TaskState; current: boolean }> }; + }>; +}; + +function itemsFor(c: (typeof fixture.cases)[number]): SafetyItem[] { + const callIdsBySeq = new Map(); + for (const [callId, pair] of buildToolPairs(c.events, c.effectiveEventSeqs)) { + for (const seq of [...pair.callSeqs, ...pair.resultSeqs]) { + callIdsBySeq.set(seq, [...(callIdsBySeq.get(seq) ?? []), callId]); + } + } + return c.expected.items.map((it) => ({ + sourceEventSeq: it.sourceEventSeq, + kind: it.kind, + taskState: it.taskState, + current: it.current, + callIds: callIdsBySeq.get(it.sourceEventSeq), + chars: 10, + })); +} + +describe("W1 DSH tool pairs match shared pairs derived from surface items", () => { + for (const c of fixture.cases) { + it(c.id, () => { + const dsh = buildToolPairs(c.events, c.effectiveEventSeqs); + const items = describeEffectiveItems(c.events, c.effectiveEventSeqs).map((it) => ({ + sourceEventSeq: it.seq, + kind: it.kind, + callIds: it.callIds, + })); + const shared = buildToolPairsFromItems(items, c.effectiveEventSeqs); + assert.deepEqual( + [...dsh.entries()].sort(([a], [b]) => a.localeCompare(b)), + [...shared.entries()].sort(([a], [b]) => a.localeCompare(b)), + ); + }); + } +}); + +describe("W2 DSH safety policy equals the shared policy", () => { + for (const c of fixture.cases) { + for (const minBlockChars of [0, 11]) { + it(`${c.id} minBlockChars=${minBlockChars}`, () => { + const items = itemsFor(c); + const dsh = applySafetyPolicy(items, c.effectiveEventSeqs, c.events, minBlockChars); + const shared = applySurfaceSafetyPolicy(items, c.effectiveEventSeqs, buildToolPairs(c.events, c.effectiveEventSeqs), minBlockChars); + assert.deepEqual(dsh.evictSeqs, [...shared.evictSeqs].sort((a, b) => a - b)); + assert.deepEqual(dsh.keepSeqs, [...shared.keepSeqs].sort((a, b) => a - b)); + assert.deepEqual(dsh.deferredCallIds, shared.deferredCallIds); + assert.deepEqual([...dsh.action.entries()].sort(), [...shared.action.entries()].sort()); + }); + } + } +}); + +describe("W3 DSH pair classification delegates to the shared classifier", () => { + const partitions: Array<[number[], number[]]> = [[[1], [2]], [[1, 2], [3]], [[1], [2, 3]], [[], [2]], [[1], []], [[], []]]; + for (const [calls, results] of partitions) { + it(`calls=${calls.length} results=${results.length}`, () => { + assert.equal(classifyPair(calls, results), classifyToolPair(calls, results)); + }); + } + it("every status has the same evictability", () => { + const statuses: ToolPairStatus[] = ["closed", "orphan_result", "missing_result", "duplicate_call", "duplicate_result"]; + for (const status of statuses) assert.equal(isEvictablePair(status), isEvictableToolPair(status), status); + }); +}); + +describe("W4 DSH estimator construction agrees with the shared helpers", () => { + const base: DshEstimatorConfig = { + enabled: true, + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "k", + model: "m", + requestTimeoutMs: 1000, + batchTurns: 5, + evictionLookaheadTurns: 3, + } as DshEstimatorConfig; + for (const mask of [0, 1, 2, 3, 4, 5, 6, 7]) { + it(`presence mask ${mask.toString(2).padStart(3, "0")} (baseUrl,apiKey,model)`, () => { + const cfg = { + ...base, + baseUrl: mask & 4 ? base.baseUrl : "", + apiKey: mask & 2 ? base.apiKey : "", + model: mask & 1 ? base.model : "", + } as DshEstimatorConfig; + assert.equal(isEstimatorConfigured(cfg), isTaskStateEstimatorConfigured(cfg)); + assert.equal(Boolean(createDshTaskStateEstimator(cfg)), Boolean(createConfiguredTaskStateEstimator(cfg))); + }); + } + it("enabled=false yields no estimator on both paths", () => { + const cfg = { ...base, enabled: false } as DshEstimatorConfig; + assert.equal(createDshTaskStateEstimator(cfg), undefined); + assert.equal(createConfiguredTaskStateEstimator(cfg), undefined); + }); +}); + +describe("W5 pre-refactor public names are still exported", () => { + it("tool-closure", () => { + for (const name of ["buildToolPairs", "classifyPair", "isEvictablePair", "resultCallId", "assistantCallIds"]) { + assert.equal(typeof (closureModule as Record)[name], "function", name); + } + }); + it("safety-policy", () => { + for (const name of ["applySafetyPolicy", "findDamagedPersistenceRecords"]) { + assert.equal(typeof (safetyModule as Record)[name], "function", name); + } + }); + it("eviction-cycle", () => { + for (const name of ["runDshEvictionCycle", "describeEffectiveItems"]) { + assert.equal(typeof (cycleModule as Record)[name], "function", name); + } + }); +}); diff --git a/components/packages/features/eviction/tests/canonical-surface-estimator-config.test.ts b/components/packages/features/eviction/tests/canonical-surface-estimator-config.test.ts new file mode 100644 index 00000000..30a50956 --- /dev/null +++ b/components/packages/features/eviction/tests/canonical-surface-estimator-config.test.ts @@ -0,0 +1,51 @@ +/** + * Regression matrix: canonical-surface/estimator-config.ts + * + * isTaskStateEstimatorConfigured — partitioned by which of {baseUrl, apiKey, model} are set + * I1 all three set → true + * I2 any one missing (3 cases) → false + * I3 empty strings count as missing + * createConfiguredTaskStateEstimator + * X1 enabled=false (even if complete) → undefined + * X2 enabled unset/true, incomplete → undefined + * X3 enabled unset, complete → estimator with estimate() + * X4 enabled=true, complete → estimator with estimate() + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { createConfiguredTaskStateEstimator, isTaskStateEstimatorConfigured } from "../src/index.js"; + +const complete = { baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", model: "m" }; + +describe("isTaskStateEstimatorConfigured", () => { + it("I1 all of baseUrl, apiKey and model set", () => { + assert.equal(isTaskStateEstimatorConfigured(complete), true); + }); + it("I2 any one field missing", () => { + for (const key of ["baseUrl", "apiKey", "model"] as const) { + const cfg: Record = { ...complete }; + delete cfg[key]; + assert.equal(isTaskStateEstimatorConfigured(cfg), false, key); + } + }); + it("I3 empty strings count as missing", () => { + assert.equal(isTaskStateEstimatorConfigured({ ...complete, model: "" }), false); + }); +}); + +describe("createConfiguredTaskStateEstimator", () => { + it("X1 explicitly disabled returns undefined even when complete", () => { + assert.equal(createConfiguredTaskStateEstimator({ ...complete, enabled: false }), undefined); + }); + it("X2 incomplete returns undefined", () => { + assert.equal(createConfiguredTaskStateEstimator({ baseUrl: complete.baseUrl, apiKey: "k" }), undefined); + assert.equal(createConfiguredTaskStateEstimator({ enabled: true, apiKey: "k", model: "m" }), undefined); + }); + it("X3 complete with enabled unset returns an estimator", () => { + assert.equal(typeof createConfiguredTaskStateEstimator(complete)?.estimate, "function"); + }); + it("X4 complete and enabled returns an estimator", () => { + assert.equal(typeof createConfiguredTaskStateEstimator({ ...complete, enabled: true })?.estimate, "function"); + }); +}); diff --git a/components/packages/features/eviction/tests/canonical-surface-eviction-cycle.test.ts b/components/packages/features/eviction/tests/canonical-surface-eviction-cycle.test.ts new file mode 100644 index 00000000..49fedb43 --- /dev/null +++ b/components/packages/features/eviction/tests/canonical-surface-eviction-cycle.test.ts @@ -0,0 +1,231 @@ +/** + * Regression matrix: canonical-surface/eviction-cycle.ts (runCanonicalEvictionCycle) + * + * Estimation (partitioned by whether the snapshot has turns past the watermark) + * D1 delta present → estimator called once with {registry, delta}; task state + * persisted first with the OLD watermark and expectedVersion + * D2 no delta → estimator not called; nothing persisted before mutation + * Decision / mutation (partitioned by safety outcome × mutation permission × host result) + * M1 nothing to evict, delta present → status "empty", watermark advanced, persisted + * M2 nothing to evict, no delta → status "no-delta", apply not called + * M3 evictions, mutation disallowed → apply not called, status "empty", watermark advanced + * M4 host commits → status "applied", watermark advanced & persisted + * M5 host partial → status "applied", watermark NOT advanced + * M6 host defers → status "deferred", watermark NOT advanced + * M7 host empty → status "deferred", watermark NOT advanced + * Persistence failures + * P1 persist throws before mutation → cycle rejects (caller fails open) + * P2 persist throws after a commit → resolves, registryPersisted=false + * P3 no persistRegistry supplied → registryPersisted=true, nothing thrown + * Inputs forwarded unchanged + * F1 compareSeqs orders the list handed to apply; apply gets a copy + * F2 minBlockChars reaches the safety policy + * F3 without compareSeqs, apply receives item order + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + createEmptySessionTaskRegistry, + type RawSemanticSnapshot, + type SessionTaskRegistry, +} from "@lightrsi/history"; + +import { + buildToolPairsFromItems, + runCanonicalEvictionCycle, + type SemanticTaskUpdate, + type SurfaceCycleItem, + type SurfaceTransactionOutcome, + type TaskStateEstimatorInput, +} from "../src/index.js"; + +const SESSION = "s1"; + +function items(): SurfaceCycleItem[] { + return [ + { sourceEventSeq: "u1", turn: 1, kind: "message", chars: 40 }, + { sourceEventSeq: "a1", turn: 1, kind: "tool_call", callIds: ["c1"], chars: 20 }, + { sourceEventSeq: "r1", turn: 1, kind: "tool_result", callIds: ["c1"], chars: 5000 }, + { sourceEventSeq: "a1b", turn: 1, kind: "message", chars: 300 }, + { sourceEventSeq: "u2", turn: 2, kind: "message", chars: 30 }, + ]; +} + +function snapshot(lastTurnSeq = 2): RawSemanticSnapshot { + const anchor = (turnSeq: number, role: "user" | "assistant" | "tool") => ({ + sessionId: SESSION, turnAbsId: `${SESSION}:t${turnSeq}`, turnSeq, role, + }); + return { + sessionId: SESSION, + lastTurnSeq, + messages: [ + { anchor: anchor(1, "user"), role: "user", text: "read the config" }, + { anchor: anchor(1, "assistant"), role: "assistant", text: "config says x" }, + { anchor: anchor(2, "user"), role: "user", text: "next" }, + ], + toolCalls: [{ anchor: anchor(1, "assistant"), toolCallId: "c1", toolName: "read", argumentsSummary: "{}" }], + toolResults: [{ anchor: anchor(1, "tool"), toolCallId: "c1", toolName: "read", status: "success", fullText: "x", summary: "x" }], + }; +} + +function estimator(updates: SemanticTaskUpdate[] = [{ + taskId: "task-config", + objective: "read the config", + lifecycle: "completed", + coveredTurnAbsIds: [`${SESSION}:t1`], + completionEvidence: ["assistant reported config value"], +}]) { + const calls: TaskStateEstimatorInput[] = []; + return { + calls, + estimate(input: TaskStateEstimatorInput) { + calls.push(input); + return { baseVersion: input.registry.version, taskUpdates: updates }; + }, + }; +} + +/** A registry that already knows t1 is completed and has processed through t2. */ +function processedRegistry(): SessionTaskRegistry { + return { + ...createEmptySessionTaskRegistry(SESSION), + completedTaskIds: ["task-config"], + turnToTaskIds: { [`${SESSION}:t1`]: ["task-config"] }, + lastProcessedTurnSeq: 2, + }; +} + +type RunOptions = Partial>[0]>; + +function run(options: RunOptions = {}) { + const list = options.items ?? items(); + const effective = list.map((it) => it.sourceEventSeq); + return runCanonicalEvictionCycle({ + snapshot: snapshot(), + registry: createEmptySessionTaskRegistry(SESSION), + estimator: estimator(), + items: list, + effectiveSeqs: effective, + pairs: buildToolPairsFromItems(list, effective), + currentTurn: 2, + minBlockChars: 256, + emptyResult: () => ({ status: "empty" }), + apply: () => ({ status: "committed" }), + ...options, + }); +} + +describe("estimation", () => { + it("D1 a delta calls the estimator once and persists task state with the old watermark first", async () => { + const est = estimator(); + const persisted: Array<{ registry: SessionTaskRegistry; expectedVersion: number }> = []; + await run({ estimator: est, persistRegistry: (registry, expectedVersion) => { persisted.push({ registry, expectedVersion }); } }); + assert.equal(est.calls.length, 1); + assert.deepEqual(est.calls[0]?.delta.coveredTurnAbsIds, [`${SESSION}:t1`, `${SESSION}:t2`]); + assert.equal(persisted[0]?.registry.lastProcessedTurnSeq, 0); + assert.equal(persisted[0]?.expectedVersion, 0); + assert.deepEqual(persisted[0]?.registry.completedTaskIds, ["task-config"]); + }); + it("D2 no delta skips the estimator and persists nothing before mutation", async () => { + const est = estimator(); + const persisted: SessionTaskRegistry[] = []; + await run({ estimator: est, registry: processedRegistry(), persistRegistry: (registry) => { persisted.push(registry); } }); + assert.equal(est.calls.length, 0); + assert.equal(persisted.length, 0); + }); +}); + +describe("decision and mutation", () => { + it("M1 nothing to evict with a delta advances and persists the watermark", async () => { + const persisted: SessionTaskRegistry[] = []; + const cycle = await run({ estimator: estimator([]), persistRegistry: (registry) => { persisted.push(registry); } }); + assert.equal(cycle.status, "empty"); + assert.equal(cycle.registry.lastProcessedTurnSeq, 2); + assert.equal(persisted.at(-1)?.lastProcessedTurnSeq, 2); + }); + it("M2 nothing to evict without a delta reports no-delta and never applies", async () => { + let applied = false; + const registry = { ...createEmptySessionTaskRegistry(SESSION), lastProcessedTurnSeq: 2 }; + const cycle = await run({ registry, apply: () => { applied = true; return { status: "committed" }; } }); + assert.equal(cycle.status, "no-delta"); + assert.equal(applied, false); + }); + it("M3 evictions with mutation disallowed track state but never apply", async () => { + let applied = false; + const cycle = await run({ allowSurfaceMutation: false, apply: () => { applied = true; return { status: "committed" }; } }); + assert.equal(applied, false); + assert.equal(cycle.status, "empty"); + assert.equal(cycle.registry.lastProcessedTurnSeq, 2); + assert.deepEqual(cycle.registry.completedTaskIds, ["task-config"]); + }); + it("M4 a committed host transaction advances and persists the watermark", async () => { + const persisted: SessionTaskRegistry[] = []; + const cycle = await run({ persistRegistry: (registry) => { persisted.push(registry); } }); + assert.equal(cycle.status, "applied"); + assert.equal(cycle.registry.lastProcessedTurnSeq, 2); + assert.equal(persisted.at(-1)?.lastProcessedTurnSeq, 2); + assert.equal(cycle.registryPersisted, true); + }); + for (const [label, status, expected] of [ + ["M5 partial", "partial", "applied"], + ["M6 deferred", "deferred", "deferred"], + ["M7 empty", "empty", "deferred"], + ] as const) { + it(`${label} host result keeps the watermark behind`, async () => { + const persisted: SessionTaskRegistry[] = []; + const cycle = await run({ apply: () => ({ status }), persistRegistry: (registry) => { persisted.push(registry); } }); + assert.equal(cycle.status, expected); + assert.equal(cycle.registry.lastProcessedTurnSeq, 0); + assert.ok(persisted.every((registry) => registry.lastProcessedTurnSeq === 0)); + }); + } +}); + +describe("persistence failures", () => { + it("P1 a failure persisting task state before mutation rejects", async () => { + await assert.rejects(run({ persistRegistry: () => { throw new Error("cas"); } }), /cas/); + }); + it("P2 a failure persisting the watermark after a commit is reported, not thrown", async () => { + let calls = 0; + const cycle = await run({ + persistRegistry: () => { + calls += 1; + if (calls > 1) throw new Error("late"); + }, + }); + assert.equal(cycle.status, "applied"); + assert.equal(cycle.registryPersisted, false); + }); + it("P3 without persistRegistry the cycle still completes", async () => { + const cycle = await run({ persistRegistry: undefined }); + assert.equal(cycle.status, "applied"); + assert.equal(cycle.registryPersisted, true); + }); +}); + +describe("forwarded inputs", () => { + it("F1 compareSeqs orders the evict list and apply receives a copy", async () => { + let received: string[] = []; + const cycle = await run({ + compareSeqs: (a, b) => b.localeCompare(a), + apply(evictSeqs) { + received = evictSeqs; + evictSeqs.push("mutated"); + return { status: "committed" }; + }, + }); + assert.deepEqual(received.slice(0, 2), ["r1", "a1b"]); + assert.deepEqual(cycle.decision?.evictSeqs, ["r1", "a1b"]); + }); + it("F2 minBlockChars reaches the safety policy", async () => { + let received: string[] = []; + await run({ minBlockChars: 1000, apply(evictSeqs) { received = evictSeqs; return { status: "committed" }; } }); + assert.deepEqual(received, ["r1"]); + }); + it("F3 without compareSeqs apply receives item order", async () => { + let received: string[] = []; + await run({ apply(evictSeqs) { received = evictSeqs; return { status: "committed" }; } }); + assert.deepEqual(received, ["r1", "a1b"]); + }); +}); diff --git a/components/packages/features/eviction/tests/canonical-surface-safety-policy.test.ts b/components/packages/features/eviction/tests/canonical-surface-safety-policy.test.ts new file mode 100644 index 00000000..76982e04 --- /dev/null +++ b/components/packages/features/eviction/tests/canonical-surface-safety-policy.test.ts @@ -0,0 +1,191 @@ +/** + * Regression matrix: canonical-surface/safety-policy.ts + * + * applySurfaceSafetyPolicy — non-tool items (not in any pair) + * N1 effective, not current, not checkpoint, completed, chars ≥ min → evict + * N2 not on the effective surface → keep + * N3 current → keep + * N4 compaction_checkpoint → keep + * N5 taskState ∈ {active, unresolved, blocked, current} → keep + * N6 chars < min → keep; N7 chars == min → evict (boundary) + * N8 missing chars treated as 0 + * applySurfaceSafetyPolicy — tool items (members of a pair) + * T1 closed pair, every member clearable, result ≥ min → result evict, call kept + * T2 closed pair, result < min → both kept, not deferred + * T3 non-closed pair → all kept, call id deferred + * T4 closed pair, a member not clearable → all kept, call id deferred + * T5 closed pair, a member missing from `items` → all kept, call id deferred + * T6 shared envelope with one unsafe call → sibling pairs deferred too + * applySurfaceSafetyPolicy — output shape + * O1 evict/keep lists partition the items and keep item order + * O2 deferredCallIds are unique and sorted + * O3 every item gets an action; default is keep + * classifySurfaceItemTaskState(turn, registry, currentTurn) + * K1 turn == currentTurn → current; K2 turn > currentTurn → current + * K3 no owning task → unresolved + * K4 every owner completed → completed + * K5 every owner evictable → completed + * K6 owners mix completed and evictable → completed + * K7 any owner active → active + * K8 an owner in no lifecycle bucket (e.g. blocked) → unresolved + * K9 lookup key is `${sessionId}:t${turn}` + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { createEmptySessionTaskRegistry, type SessionTaskRegistry } from "@lightrsi/history"; + +import { + applySurfaceSafetyPolicy, + buildToolPairsFromItems, + classifySurfaceItemTaskState, + type SurfaceSafetyItem, + type SurfaceTaskState, +} from "../src/index.js"; + +type Item = SurfaceSafetyItem; + +function msg(id: string, patch: Partial = {}): Item { + return { sourceEventSeq: id, kind: "message", taskState: "completed", current: false, chars: 500, ...patch }; +} + +function decide(items: Item[], options: { effective?: string[]; min?: number } = {}) { + const effective = options.effective ?? items.map((it) => it.sourceEventSeq); + return applySurfaceSafetyPolicy(items, effective, buildToolPairsFromItems(items, effective), options.min ?? 0); +} + +describe("applySurfaceSafetyPolicy: non-tool items", () => { + it("N1 a clearable message is evicted", () => { + assert.deepEqual(decide([msg("m")], { min: 100 }).evictSeqs, ["m"]); + }); + it("N2 a message off the effective surface is kept", () => { + assert.deepEqual(decide([msg("m")], { effective: [] }).evictSeqs, []); + }); + it("N3 a current message is kept", () => { + assert.deepEqual(decide([msg("m", { current: true })]).evictSeqs, []); + }); + it("N4 a compaction checkpoint is kept", () => { + assert.deepEqual(decide([msg("m", { kind: "compaction_checkpoint" })]).evictSeqs, []); + }); + it("N5 every non-completed task state is kept", () => { + const states: SurfaceTaskState[] = ["active", "unresolved", "blocked", "current"]; + for (const taskState of states) { + assert.deepEqual(decide([msg("m", { taskState })]).evictSeqs, [], taskState); + } + }); + it("N6 a message below minBlockChars is kept", () => { + assert.deepEqual(decide([msg("m", { chars: 99 })], { min: 100 }).evictSeqs, []); + }); + it("N7 a message exactly at minBlockChars is evicted", () => { + assert.deepEqual(decide([msg("m", { chars: 100 })], { min: 100 }).evictSeqs, ["m"]); + }); + it("N8 a message without chars counts as zero", () => { + const item = msg("m"); + delete item.chars; + assert.deepEqual(decide([item], { min: 1 }).evictSeqs, []); + assert.deepEqual(decide([item], { min: 0 }).evictSeqs, ["m"]); + }); +}); + +describe("applySurfaceSafetyPolicy: tool items", () => { + const call = (id: string, callIds: string[], patch: Partial = {}) => msg(id, { kind: "tool_call", callIds, chars: 20, ...patch }); + const result = (id: string, callId: string, patch: Partial = {}) => msg(id, { kind: "tool_result", callIds: [callId], chars: 5000, ...patch }); + + it("T1 a closed, clearable pair evicts the result and keeps the call envelope", () => { + const d = decide([call("a", ["c"]), result("r", "c")], { min: 1000 }); + assert.deepEqual(d.evictSeqs, ["r"]); + assert.deepEqual(d.keepSeqs, ["a"]); + assert.deepEqual(d.deferredCallIds, []); + }); + it("T2 a closed pair whose result is below minBlockChars is kept but not deferred", () => { + const d = decide([call("a", ["c"]), result("r", "c", { chars: 10 })], { min: 1000 }); + assert.deepEqual(d.evictSeqs, []); + assert.deepEqual(d.deferredCallIds, []); + }); + it("T3 a non-closed pair is kept and deferred", () => { + const d = decide([result("r", "c")]); + assert.deepEqual(d.evictSeqs, []); + assert.deepEqual(d.deferredCallIds, ["c"]); + }); + it("T4 a closed pair with a non-clearable member is kept and deferred", () => { + const d = decide([call("a", ["c"], { taskState: "active" }), result("r", "c")]); + assert.deepEqual(d.evictSeqs, []); + assert.deepEqual(d.deferredCallIds, ["c"]); + }); + it("T5 a closed pair with a member missing from the item list is kept and deferred", () => { + const items = [call("a", ["c"]), result("r", "c")]; + const pairs = buildToolPairsFromItems(items, ["a", "r"]); + const d = applySurfaceSafetyPolicy([items[1]!], ["a", "r"], pairs, 0); + assert.deepEqual(d.evictSeqs, []); + assert.deepEqual(d.deferredCallIds, ["c"]); + }); + it("T6 one unsafe call in a shared envelope defers every sibling pair", () => { + const d = decide([ + call("a", ["c1", "c2"]), + result("r1", "c1"), + result("r2", "c2", { taskState: "active" }), + ]); + assert.deepEqual(d.evictSeqs, []); + assert.deepEqual(d.deferredCallIds, ["c1", "c2"]); + }); +}); + +describe("applySurfaceSafetyPolicy: output shape", () => { + it("O1 evict and keep lists partition the items in item order", () => { + const d = decide([msg("m1"), msg("m2", { current: true }), msg("m3")]); + assert.deepEqual(d.evictSeqs, ["m1", "m3"]); + assert.deepEqual(d.keepSeqs, ["m2"]); + }); + it("O2 deferred call ids are unique and sorted", () => { + const d = decide([ + msg("a", { kind: "tool_call", callIds: ["z", "b"] }), + msg("r", { kind: "tool_result", callIds: ["z"], taskState: "active" }), + ]); + assert.deepEqual(d.deferredCallIds, ["b", "z"]); + }); + it("O3 every item receives an action", () => { + const d = decide([msg("m1"), msg("m2", { current: true })]); + assert.deepEqual([...d.action.entries()], [["m1", "evict"], ["m2", "keep"]]); + }); +}); + +describe("classifySurfaceItemTaskState", () => { + const SESSION = "s"; + function registry(patch: Partial = {}): SessionTaskRegistry { + return { ...createEmptySessionTaskRegistry(SESSION), ...patch }; + } + const owned = (taskIds: string[], patch: Partial = {}) => + registry({ turnToTaskIds: { [`${SESSION}:t1`]: taskIds }, ...patch }); + + it("K1 the current turn is current", () => { + assert.deepEqual(classifySurfaceItemTaskState(2, owned(["t"], { completedTaskIds: ["t"] }), 2), { taskState: "current", current: true }); + }); + it("K2 a turn after the current turn is current", () => { + assert.deepEqual(classifySurfaceItemTaskState(3, registry(), 2), { taskState: "current", current: true }); + }); + it("K3 a turn with no owning task is unresolved", () => { + assert.deepEqual(classifySurfaceItemTaskState(1, registry(), 2), { taskState: "unresolved", current: false }); + }); + it("K4 a turn whose owners are all completed is completed", () => { + assert.equal(classifySurfaceItemTaskState(1, owned(["t"], { completedTaskIds: ["t"] }), 2).taskState, "completed"); + }); + it("K5 a turn whose owners are all evictable is completed", () => { + assert.equal(classifySurfaceItemTaskState(1, owned(["t"], { evictableTaskIds: ["t"] }), 2).taskState, "completed"); + }); + it("K6 a turn whose owners mix completed and evictable is completed", () => { + const r = owned(["a", "b"], { completedTaskIds: ["a"], evictableTaskIds: ["b"] }); + assert.equal(classifySurfaceItemTaskState(1, r, 2).taskState, "completed"); + }); + it("K7 a turn with any active owner is active", () => { + const r = owned(["a", "b"], { completedTaskIds: ["a"], activeTaskIds: ["b"] }); + assert.equal(classifySurfaceItemTaskState(1, r, 2).taskState, "active"); + }); + it("K8 a turn with an owner in no lifecycle bucket is unresolved", () => { + const r = owned(["a", "b"], { completedTaskIds: ["a"] }); + assert.equal(classifySurfaceItemTaskState(1, r, 2).taskState, "unresolved"); + }); + it("K9 the lookup key is scoped by session id", () => { + const r = registry({ completedTaskIds: ["t"], turnToTaskIds: { "other:t1": ["t"] } }); + assert.equal(classifySurfaceItemTaskState(1, r, 2).taskState, "unresolved"); + }); +}); diff --git a/components/packages/features/eviction/tests/canonical-surface-tool-closure.test.ts b/components/packages/features/eviction/tests/canonical-surface-tool-closure.test.ts new file mode 100644 index 00000000..3e608c8f --- /dev/null +++ b/components/packages/features/eviction/tests/canonical-surface-tool-closure.test.ts @@ -0,0 +1,138 @@ +/** + * Regression matrix: canonical-surface/tool-closure.ts + * + * classifyToolPair(calls, results) — partitioned by (#calls, #results) + * C1 calls=1 results=1 → closed + * C2 calls≥2 (any results) → duplicate_call (checked first) + * C3 calls≤1 results≥2 → duplicate_result (checked before C4) + * C4 calls=0 results=1 → orphan_result + * C5 calls=1 results=0 → missing_result + * C6 calls=0 results=0 → orphan_result (degenerate; pinned) + * isEvictableToolPair(status) — one case per status value + * E1 closed → true; E2 every other status → false + * assembleToolPairs(calls, results, cmp) + * A1 key in both maps → one pair with both memberships + * A2 key only in calls / only in results → pair with empty other side + * A3 member ids are ordered by `cmp`, not insertion order + * A4 input arrays are not mutated + * buildToolPairsFromItems(items, effective) + * B1 tool_call items contribute calls, tool_result items contribute results + * B2 message / compaction_checkpoint items contribute nothing + * B3 items outside `effective` contribute nothing + * B4 empty call ids are ignored + * B5 one envelope carrying several call ids joins each pair + * B6 members are ordered by item position + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + assembleToolPairs, + buildToolPairsFromItems, + classifyToolPair, + isEvictableToolPair, + type ToolPairStatus, +} from "../src/index.js"; + +describe("classifyToolPair", () => { + it("C1 one call and one result is closed", () => { + assert.equal(classifyToolPair([1], [2]), "closed"); + }); + it("C2 two or more calls is duplicate_call regardless of results", () => { + assert.equal(classifyToolPair([1, 2], []), "duplicate_call"); + assert.equal(classifyToolPair([1, 2], [3]), "duplicate_call"); + assert.equal(classifyToolPair([1, 2], [3, 4]), "duplicate_call"); + }); + it("C3 at most one call with two or more results is duplicate_result", () => { + assert.equal(classifyToolPair([1], [2, 3]), "duplicate_result"); + assert.equal(classifyToolPair([], [2, 3]), "duplicate_result"); + }); + it("C4 no call with one result is orphan_result", () => { + assert.equal(classifyToolPair([], [2]), "orphan_result"); + }); + it("C5 one call without a result is missing_result", () => { + assert.equal(classifyToolPair([1], []), "missing_result"); + }); + it("C6 no call and no result is orphan_result", () => { + assert.equal(classifyToolPair([], []), "orphan_result"); + }); +}); + +describe("isEvictableToolPair", () => { + it("E1 closed is evictable", () => { + assert.equal(isEvictableToolPair("closed"), true); + }); + it("E2 every non-closed status is not evictable", () => { + const others: ToolPairStatus[] = ["orphan_result", "missing_result", "duplicate_call", "duplicate_result"]; + for (const status of others) assert.equal(isEvictableToolPair(status), false, status); + }); +}); + +describe("assembleToolPairs", () => { + const numeric = (a: number, b: number) => a - b; + it("A1 a call id present in both maps yields one pair with both sides", () => { + const pairs = assembleToolPairs(new Map([["c", [1]]]), new Map([["c", [2]]]), numeric); + assert.deepEqual([...pairs.values()], [{ callId: "c", callSeqs: [1], resultSeqs: [2], status: "closed" }]); + }); + it("A2 a call id present on one side yields a pair with the other side empty", () => { + const pairs = assembleToolPairs(new Map([["a", [1]]]), new Map([["b", [2]]]), numeric); + assert.deepEqual(pairs.get("a"), { callId: "a", callSeqs: [1], resultSeqs: [], status: "missing_result" }); + assert.deepEqual(pairs.get("b"), { callId: "b", callSeqs: [], resultSeqs: [2], status: "orphan_result" }); + }); + it("A3 members are ordered by the comparator", () => { + const pairs = assembleToolPairs(new Map([["c", [9, 3]]]), new Map([["c", [7, 5]]]), numeric); + assert.deepEqual(pairs.get("c")?.callSeqs, [3, 9]); + assert.deepEqual(pairs.get("c")?.resultSeqs, [5, 7]); + }); + it("A4 the input membership arrays are not mutated", () => { + const calls = [9, 3]; + assembleToolPairs(new Map([["c", calls]]), new Map(), numeric); + assert.deepEqual(calls, [9, 3]); + }); +}); + +describe("buildToolPairsFromItems", () => { + it("B1 tool_call items are calls and tool_result items are results", () => { + const pairs = buildToolPairsFromItems([ + { sourceEventSeq: "a", kind: "tool_call", callIds: ["c1"] }, + { sourceEventSeq: "r", kind: "tool_result", callIds: ["c1"] }, + ], ["a", "r"]); + assert.deepEqual(pairs.get("c1"), { callId: "c1", callSeqs: ["a"], resultSeqs: ["r"], status: "closed" }); + }); + it("B2 message and checkpoint items never join a pair", () => { + const pairs = buildToolPairsFromItems([ + { sourceEventSeq: "m", kind: "message", callIds: ["c1"] }, + { sourceEventSeq: "k", kind: "compaction_checkpoint", callIds: ["c1"] }, + ], ["m", "k"]); + assert.equal(pairs.size, 0); + }); + it("B3 items outside the effective surface never join a pair", () => { + const pairs = buildToolPairsFromItems([ + { sourceEventSeq: "a", kind: "tool_call", callIds: ["c1"] }, + { sourceEventSeq: "r", kind: "tool_result", callIds: ["c1"] }, + ], ["r"]); + assert.deepEqual(pairs.get("c1"), { callId: "c1", callSeqs: [], resultSeqs: ["r"], status: "orphan_result" }); + }); + it("B4 empty call ids are ignored", () => { + const pairs = buildToolPairsFromItems([{ sourceEventSeq: "a", kind: "tool_call", callIds: [""] }], ["a"]); + assert.equal(pairs.size, 0); + }); + it("B5 one envelope with several call ids joins each of those pairs", () => { + const pairs = buildToolPairsFromItems([ + { sourceEventSeq: "a", kind: "tool_call", callIds: ["c1", "c2"] }, + { sourceEventSeq: "r1", kind: "tool_result", callIds: ["c1"] }, + { sourceEventSeq: "r2", kind: "tool_result", callIds: ["c2"] }, + ], ["a", "r1", "r2"]); + assert.deepEqual(pairs.get("c1")?.callSeqs, ["a"]); + assert.deepEqual(pairs.get("c2")?.callSeqs, ["a"]); + assert.equal(pairs.get("c1")?.status, "closed"); + assert.equal(pairs.get("c2")?.status, "closed"); + }); + it("B6 members are ordered by their position in the item list", () => { + const pairs = buildToolPairsFromItems([ + { sourceEventSeq: "z", kind: "tool_result", callIds: ["c1"] }, + { sourceEventSeq: "a", kind: "tool_result", callIds: ["c1"] }, + ], ["a", "z"]); + assert.deepEqual(pairs.get("c1")?.resultSeqs, ["z", "a"]); + }); +}); diff --git a/components/packages/features/eviction/tests/canonical-surface.test.ts b/components/packages/features/eviction/tests/canonical-surface.test.ts deleted file mode 100644 index 4969eb3d..00000000 --- a/components/packages/features/eviction/tests/canonical-surface.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; - -import { - createEmptySessionTaskRegistry, - type RawSemanticSnapshot, - type SessionTaskRegistry, -} from "@lightrsi/history"; - -import { - applySurfaceSafetyPolicy, - buildToolPairsFromItems, - classifySurfaceItemTaskState, - createConfiguredTaskStateEstimator, - isTaskStateEstimatorConfigured, - runCanonicalEvictionCycle, - type SemanticTaskUpdate, - type SurfaceCycleItem, - type SurfaceSafetyItem, - type SurfaceTransactionOutcome, -} from "../src/index.js"; - -const SESSION = "s1"; - -/** Two turns: t1 is a finished task with one tool pair, t2 is current. */ -function items(): SurfaceCycleItem[] { - return [ - { sourceEventSeq: "u1", turn: 1, kind: "message", chars: 40 }, - { sourceEventSeq: "a1", turn: 1, kind: "tool_call", callIds: ["c1"], chars: 20 }, - { sourceEventSeq: "r1", turn: 1, kind: "tool_result", callIds: ["c1"], chars: 5000 }, - { sourceEventSeq: "a1b", turn: 1, kind: "message", chars: 300 }, - { sourceEventSeq: "u2", turn: 2, kind: "message", chars: 30 }, - ]; -} - -function snapshot(): RawSemanticSnapshot { - const anchor = (turnSeq: number, role: "user" | "assistant" | "tool") => ({ - sessionId: SESSION, turnAbsId: `${SESSION}:t${turnSeq}`, turnSeq, role, - }); - return { - sessionId: SESSION, - lastTurnSeq: 2, - messages: [ - { anchor: anchor(1, "user"), role: "user", text: "read the config" }, - { anchor: anchor(1, "assistant"), role: "assistant", text: "done, config says x" }, - { anchor: anchor(2, "user"), role: "user", text: "now something else" }, - ], - toolCalls: [{ anchor: anchor(1, "assistant"), toolCallId: "c1", toolName: "read", argumentsSummary: "{}" }], - toolResults: [{ - anchor: anchor(1, "tool"), toolCallId: "c1", toolName: "read", status: "success", fullText: "x".repeat(5000), summary: "x", - }], - }; -} - -function completedT1Estimator() { - const taskUpdates: SemanticTaskUpdate[] = [{ - taskId: "task-config", - objective: "read the config", - lifecycle: "completed", - coveredTurnAbsIds: [`${SESSION}:t1`], - completionEvidence: ["assistant reported config value"], - }]; - return { estimate: () => ({ baseVersion: 0, taskUpdates }) }; -} - -describe("canonical-surface tool closure", () => { - it("pairs string-id items and flags unclosed groups", () => { - const pairs = buildToolPairsFromItems([ - { sourceEventSeq: "a", kind: "tool_call", callIds: ["c1", "c2"] }, - { sourceEventSeq: "b", kind: "tool_result", callIds: ["c1"] }, - { sourceEventSeq: "c", kind: "tool_result", callIds: ["c3"] }, - ], ["a", "b", "c"]); - assert.equal(pairs.get("c1")?.status, "closed"); - assert.equal(pairs.get("c2")?.status, "missing_result"); - assert.equal(pairs.get("c3")?.status, "orphan_result"); - }); - - it("ignores items that are not on the effective surface", () => { - const pairs = buildToolPairsFromItems([ - { sourceEventSeq: "a", kind: "tool_call", callIds: ["c1"] }, - { sourceEventSeq: "b", kind: "tool_result", callIds: ["c1"] }, - ], ["b"]); - assert.equal(pairs.get("c1")?.status, "orphan_result"); - }); -}); - -describe("canonical-surface safety policy", () => { - it("evicts only a closed, completed tool result above minBlockChars and never the call envelope", () => { - const safety: SurfaceSafetyItem[] = [ - { sourceEventSeq: "a1", kind: "tool_call", callIds: ["c1"], taskState: "completed", current: false, chars: 20 }, - { sourceEventSeq: "r1", kind: "tool_result", callIds: ["c1"], taskState: "completed", current: false, chars: 5000 }, - { sourceEventSeq: "u2", kind: "message", taskState: "current", current: true, chars: 30 }, - ]; - const effective = ["a1", "r1", "u2"]; - const decision = applySurfaceSafetyPolicy(safety, effective, buildToolPairsFromItems(safety, effective), 1000); - assert.deepEqual(decision.evictSeqs, ["r1"]); - assert.deepEqual(decision.keepSeqs.sort(), ["a1", "u2"]); - }); - - it("defers every sibling when a shared assistant envelope has an unsafe call", () => { - const safety: SurfaceSafetyItem[] = [ - { sourceEventSeq: "a1", kind: "tool_call", callIds: ["c1", "c2"], taskState: "completed", current: false, chars: 20 }, - { sourceEventSeq: "r1", kind: "tool_result", callIds: ["c1"], taskState: "completed", current: false, chars: 5000 }, - ]; - const decision = applySurfaceSafetyPolicy(safety, ["a1", "r1"], buildToolPairsFromItems(safety, ["a1", "r1"])); - assert.deepEqual(decision.evictSeqs, []); - assert.deepEqual(decision.deferredCallIds, ["c1", "c2"]); - }); - - it("classifies task state from the registry only", () => { - const registry: SessionTaskRegistry = { - ...createEmptySessionTaskRegistry(SESSION), - completedTaskIds: ["t"], - turnToTaskIds: { [`${SESSION}:t1`]: ["t"] }, - }; - assert.deepEqual(classifySurfaceItemTaskState(1, registry, 2), { taskState: "completed", current: false }); - assert.deepEqual(classifySurfaceItemTaskState(2, registry, 2), { taskState: "current", current: true }); - assert.deepEqual(classifySurfaceItemTaskState(0, registry, 2), { taskState: "unresolved", current: false }); - }); -}); - -describe("canonical-surface eviction cycle", () => { - it("estimates, persists, applies the safe set, and advances the watermark", async () => { - const persisted: SessionTaskRegistry[] = []; - let applied: string[] = []; - const list = items(); - const cycle = await runCanonicalEvictionCycle({ - snapshot: snapshot(), - registry: createEmptySessionTaskRegistry(SESSION), - estimator: completedT1Estimator(), - items: list, - effectiveSeqs: list.map((it) => it.sourceEventSeq), - pairs: buildToolPairsFromItems(list, list.map((it) => it.sourceEventSeq)), - currentTurn: 2, - minBlockChars: 256, - persistRegistry: (registry) => { persisted.push(registry); }, - emptyResult: (): SurfaceTransactionOutcome => ({ status: "empty" }), - apply(evictSeqs) { - applied = evictSeqs; - return { status: "committed" as const }; - }, - }); - assert.equal(cycle.status, "applied"); - assert.deepEqual(applied, ["r1", "a1b"]); - assert.equal(cycle.registry.lastProcessedTurnSeq, 2); - // Task state lands first with the old watermark, then the watermark advances. - assert.equal(persisted[0]?.lastProcessedTurnSeq, 0); - assert.equal(persisted.at(-1)?.lastProcessedTurnSeq, 2); - }); - - it("tracks task state without mutating when surface mutation is not allowed", async () => { - let called = false; - const list = items(); - const cycle = await runCanonicalEvictionCycle({ - snapshot: snapshot(), - registry: createEmptySessionTaskRegistry(SESSION), - estimator: completedT1Estimator(), - items: list, - effectiveSeqs: list.map((it) => it.sourceEventSeq), - pairs: buildToolPairsFromItems(list, list.map((it) => it.sourceEventSeq)), - currentTurn: 2, - allowSurfaceMutation: false, - emptyResult: (): SurfaceTransactionOutcome => ({ status: "empty" }), - apply() { called = true; return { status: "committed" as const }; }, - }); - assert.equal(called, false); - assert.equal(cycle.status, "empty"); - assert.deepEqual(cycle.registry.completedTaskIds, ["task-config"]); - }); - - it("keeps the watermark behind when the host transaction defers", async () => { - const list = items(); - const cycle = await runCanonicalEvictionCycle({ - snapshot: snapshot(), - registry: createEmptySessionTaskRegistry(SESSION), - estimator: completedT1Estimator(), - items: list, - effectiveSeqs: list.map((it) => it.sourceEventSeq), - pairs: buildToolPairsFromItems(list, list.map((it) => it.sourceEventSeq)), - currentTurn: 2, - emptyResult: (): SurfaceTransactionOutcome => ({ status: "empty" }), - apply: () => ({ status: "deferred" as const }), - }); - assert.equal(cycle.status, "deferred"); - assert.equal(cycle.registry.lastProcessedTurnSeq, 0); - }); -}); - -describe("canonical-surface estimator config", () => { - it("requires baseUrl, apiKey and model", () => { - assert.equal(isTaskStateEstimatorConfigured({ baseUrl: "http://x", apiKey: "k" }), false); - assert.equal(isTaskStateEstimatorConfigured({ baseUrl: "http://x", apiKey: "k", model: "m" }), true); - assert.equal(createConfiguredTaskStateEstimator({ baseUrl: "http://x", apiKey: "k" }), undefined); - assert.equal(createConfiguredTaskStateEstimator({ enabled: false, baseUrl: "http://x", apiKey: "k", model: "m" }), undefined); - assert.ok(createConfiguredTaskStateEstimator({ baseUrl: "http://x", apiKey: "k", model: "m" })); - }); -});