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/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/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-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"]); + }); +});