mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 00:11:35 +00:00
* refactor(sessions): keep helper transcripts in memory * refactor(sessions): remove file-era transcript storage * test(sessions): use SQLite identity in attempt persistence * test(codex): isolate legacy transcript fixtures * fix(sessions): preserve SQLite transcript identity * fix(sessions): harden transcript lifecycle invariants * fix(sessions): validate transcript identities * fix(sessions): close identity compatibility gaps * fix(sessions): preserve leaf and plugin identities * fix(sessions): retain dispatch transcript targets * fix(sessions): preserve active transcript context * fix(sessions): isolate artifact accounting * fix(sessions): bound SQLite usage accounting * fix(sessions): retain bounded latest usage * fix(sessions): align rebased transcript targets * test(sessions): align accessor scope fixture * fix(telegram): derive SQLite transcript identity * refactor(sessions): remove file-era compaction residue * chore(sessions): lower max-lines baseline * fix(sessions): preserve structured transcript identity * test(sessions): align doctor identity assertions * fix(sessions): isolate default SDK database * refactor(sessions): remove dead file-era exports * fix(sessions): reconcile SQLite transcript identity * fix(sessions): pass checkpoint identity explicitly * test(sessions): make entry field probe explicit * test(sessions): satisfy transcript cleanup lint * test(sessions): align diagnostics identity proof * fix(sessions): finish transcript runtime teardown * fix(sessions): preserve transcript identity invariants * fix(sessions): harden transcript compatibility edges * fix(sessions): preserve checkpoint transcript anchors * fix(sessions): preserve SQLite lifecycle invariants * fix(sessions): retarget compaction successors * test(sessions): preserve transcript fixture semantics * feat(plugin-sdk): add command transcript targets * fix(sessions): serialize transcript rewrites * fix(sessions): validate legacy successor identity * fix(sessions): normalize compaction ownership * fix(sessions): validate successor identity before adoption * fix(sessions): preserve plugin transcript ownership * fix(sessions): carry transcript identity through commands * fix(sessions): import legacy checkpoint artifacts into SQLite * fix(sessions): preserve successor transcript ownership * fix(sessions): align transcript consumers with target identity * fix(sessions): scope transcript token estimates * fix(sessions): retain agent identity across lifecycle hooks * fix(sessions): resolve scoped SQLite targets * fix(sessions): isolate lifecycle transcript targets * fix(sessions): validate compaction agent ownership * fix(sessions): preserve reset and cleanup lifecycle * fix(sessions): serialize prompt cleanup lifecycle * fix(sessions): remove stale lock import * fix(sessions): preserve reset target context * fix(sessions): fence prompt reload takeover * fix(sessions): unblock abort and default lifecycle reads * fix(sessions): validate legacy successor scope * fix(sessions): reject metadata-only runtime rows * fix(sessions): propagate custom transcript stores * fix(sessions): preserve adopted retry targets * fix(sessions): allow unkeyed usage reads * fix(sessions): harden runtime target boundaries * fix(sessions): serialize retry transcript writes * fix(sessions): bound prompt reload disposal * fix(sessions): complete retry marker identity * fix(sessions): keep legacy marker identity minimal * test(sessions): tighten teardown fixture types * fix(sessions): preserve compatibility target identity * test(sessions): persist post-checkpoint boundary turn * test(sessions): align runtime store mock contracts * style(sessions): simplify persisted identity guard * fix(sessions): prefer complete typed targets * fix(sessions): recover legacy marker targets * test(sessions): align marker lookup fixture scope * fix(sessions): validate partial transcript targets * fix(sessions): reconcile partial transcript identities * fix(sessions): canonicalize compatibility identities * test(sessions): cover compatibility aliases * fix(sessions): adopt legacy successor identity * fix(sessions): preserve usage read identity * fix(sessions): preserve partial marker compatibility * fix(sessions): validate legacy successor mappings * fix(sessions): reconcile marker store mappings * fix(sessions): preserve legacy fallback identity * fix(sessions): harden marker alias resolution * fix(sessions): prefer verified successor aliases * fix(sessions): resolve preferred marker aliases * fix(sessions): serialize cleanup admission * fix(sessions): align marker lookup scopes * fix(codex): type marker alias summaries * style(sessions): satisfy changed lint * test(sessions): align structured target assertions * fix(sessions): reconcile latest identity contracts * fix(sessions): validate transcript identity boundaries * docs(sessions): explain stable registry keys * fix(sessions): harden compatibility target round trips * fix(sessions): port usage identity to split modules * test(sessions): align subagent transcript identity * fix(sessions): finish transcript identity migration * fix(agents): route subagent completion capture through transcript targets * fix(agents): settle SQLite prompt handoff during cleanup * chore: shrink max-lines baseline after teardown * fix(sessions): port teardown across split runtime owners * fix(sessions): carry transcript targets through split owners * test(agents): use SQLite compaction target in abort coverage * chore: retain unrelated max-lines suppressions * chore: shrink max-lines baseline after main splits * style(agents): const compaction checkpoint locals * fix(sessions): harden SQLite teardown boundaries * test(sessions): use typed metadata in predicate isolation fixture * test(agents): cover malformed settlement rejections lint-safely * fix(sessions): close remaining SQLite identity races * fix(agents): fail closed on incomplete successor targets * fix(sessions): preserve transcript identity fallbacks * fix(agents): preserve session-key abort admission * fix(trajectory): validate incomplete export targets * test(sessions): drop retired pricing cache imports * fix(sessions): validate partial transcript identities * fix(sessions): close transcript identity edge cases * fix(plugins): reserve retired transcript locator slot * fix(sessions): scope transcript locks by target * style(sessions): simplify SDK initialization error * fix(sessions): preserve initialized transcript state * fix(codex): verify mirrored history session keys * fix(sessions): reject stale transcript ownership * fix(sessions): anchor asynchronous transcript ownership * fix(sessions): measure active transcript state * fix(sessions): preserve scoped transcript compaction * fix(sessions): harden transcript identity and lifecycle * fix(sessions): resolve scoped command transcript stores * fix(sessions): make transcript appends failure-atomic * fix(sessions): enforce scoped transcript ownership * fix(sessions): reject cross-owner transcript handoffs * fix(sessions): fence cleanup transcript ownership * fix(sessions): retire stale write ownership contexts * fix(sessions): preserve pending session migration state * fix(sessions): validate migrated transcript ownership * fix(sessions): validate usage transcript targets * fix(sessions): clear predecessor transcript metadata * fix(sessions): align durable session event targets * fix(sessions): fence late prompt handoffs * fix(sessions): fence lifecycle transcript fallbacks * fix(sessions): bound zero-length memory capture * fix(sessions): preserve transcript teardown ownership * fix(sessions): reject duplicate cleanup ownership * fix(sessions): serialize runtime writes with sqlite leases * fix(sessions): close sqlite teardown concurrency gaps * fix(sessions): preserve nested lifecycle failures * fix(sessions): canonicalize sqlite transcript ownership * fix(sessions): settle disposed prompt handoffs * fix(sessions): resolve canonical attempt lock targets * test(sessions): align canonical target fixtures * test(sessions): retire redundant jsonl parser coverage * refactor(sessions): split active transcript cursors * test(memory): retire legacy marker fixture * fix(sessions): preserve canonical transcript access after rebase * fix(sessions): fence prompt lease and return transcript targets * fix(sessions): colocate transcript leases with target store * fix(sessions): canonicalize transcript lease and worker targets * fix(sessions): preserve plugin and fork identity markers * fix(sessions): complete sqlite transcript target migration * fix(sessions): integrate canonical followup identity * fix(sessions): preserve bounded transcript topology * fix(sessions): validate transcript identity boundaries * fix(context): separate caller and successor targets * test(sessions): split persistence compatibility coverage * test(sessions): preserve fixture topology efficiently * chore(sdk): refresh plugin api baseline * test(agents): align compaction lock target mocks * test(sessions): seed malformed transcript fixtures directly * fix(agents): canonicalize transcript compatibility inputs * fix(agents): type optional tool result ids * test(ci): stabilize loaded process timing * test(tui): wait for collect queue admission
2117 lines
73 KiB
TypeScript
2117 lines
73 KiB
TypeScript
// Context engine tests cover context extraction and prompt context assembly.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { upsertSessionEntry } from "../config/sessions/session-accessor.js";
|
|
import type { MemoryCitationsMode } from "../config/types.memory.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import {
|
|
clearMemoryPluginState,
|
|
registerMemoryPromptPreparation,
|
|
registerTestMemoryPromptBuilder,
|
|
} from "../plugins/memory-state.test-fixtures.js";
|
|
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
|
// ---------------------------------------------------------------------------
|
|
// We dynamically import the registry so we can get a fresh module per test
|
|
// group when needed. For most groups we use the shared singleton directly.
|
|
// ---------------------------------------------------------------------------
|
|
import {
|
|
buildMemorySystemPromptAddition,
|
|
delegateCompactionToRuntime,
|
|
prepareMemorySystemPromptAddition,
|
|
} from "./delegate.js";
|
|
import { LegacyContextEngine } from "./legacy.js";
|
|
import { registerLegacyContextEngine } from "./legacy.registration.js";
|
|
import {
|
|
registerContextEngineForOwner,
|
|
getContextEngineRegistration,
|
|
listContextEngineQuarantines,
|
|
resolveContextEngine,
|
|
resolveContextEngineOwnerPluginId,
|
|
} from "./registry.js";
|
|
import {
|
|
captureContextEngineRegistryStateForTests,
|
|
resetContextEngineRuntimeQuarantineForTests,
|
|
} from "./registry.test-support.js";
|
|
import type {
|
|
ContextEngine,
|
|
ContextEngineInfo,
|
|
ContextEngineSessionTarget,
|
|
AssembleResult,
|
|
CompactResult,
|
|
ContextEngineMaintenanceResult,
|
|
BootstrapResult,
|
|
IngestResult,
|
|
} from "./types.js";
|
|
|
|
type ContextEngineFactory = Parameters<typeof registerContextEngineForOwner>[1];
|
|
type ContextEngineFactoryContext = Parameters<ContextEngineFactory>[0];
|
|
|
|
function registerTestContextEngine(id: string, factory: ContextEngineFactory) {
|
|
return registerContextEngineForOwner(id, factory, `test:${id}`, {
|
|
allowSameOwnerRefresh: true,
|
|
});
|
|
}
|
|
|
|
const { compactEmbeddedAgentSessionDirectMock } = vi.hoisted(() => ({
|
|
compactEmbeddedAgentSessionDirectMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../agents/embedded-agent-runner/compact.runtime.js", () => ({
|
|
compactEmbeddedAgentSessionDirect: compactEmbeddedAgentSessionDirectMock,
|
|
}));
|
|
|
|
function installCompactRuntimeSpy() {
|
|
return compactEmbeddedAgentSessionDirectMock.mockResolvedValue({
|
|
ok: true,
|
|
compacted: false,
|
|
reason: "mock compaction",
|
|
result: {
|
|
summary: "",
|
|
firstKeptEntryId: "",
|
|
tokensBefore: 0,
|
|
tokensAfter: 0,
|
|
details: undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
function requireCompactRuntimeParams(callIndex: number): Record<string, unknown> {
|
|
const params = compactEmbeddedAgentSessionDirectMock.mock.calls[callIndex]?.[0] as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
if (!params) {
|
|
throw new Error(`missing compact runtime call ${callIndex}`);
|
|
}
|
|
return params;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Build a config object with a contextEngine slot for testing. */
|
|
function configWithSlot(engineId: string): OpenClawConfig {
|
|
return { plugins: { slots: { contextEngine: engineId } } };
|
|
}
|
|
|
|
function makeMockMessage(role: "user" | "assistant" = "user", text = "hello"): AgentMessage {
|
|
return { role, content: text, timestamp: Date.now() } as AgentMessage;
|
|
}
|
|
|
|
let restoreContextEngineRegistry = () => {};
|
|
|
|
beforeAll(() => {
|
|
restoreContextEngineRegistry = captureContextEngineRegistryStateForTests();
|
|
});
|
|
|
|
afterAll(() => {
|
|
restoreContextEngineRegistry();
|
|
});
|
|
|
|
let uniqueEngineIdCounter = 0;
|
|
function uniqueEngineId(prefix: string): string {
|
|
uniqueEngineIdCounter += 1;
|
|
return `${prefix}-${uniqueEngineIdCounter}`;
|
|
}
|
|
|
|
function registerPromptTrackingEngine(engineId: string) {
|
|
const calls: Array<Record<string, unknown>> = [];
|
|
registerTestContextEngine(engineId, () => ({
|
|
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
|
|
async ingest() {
|
|
return { ingested: false };
|
|
},
|
|
async assemble(params) {
|
|
calls.push({ ...params });
|
|
return { messages: params.messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
}));
|
|
return calls;
|
|
}
|
|
|
|
function requireFactoryContext(
|
|
context: ContextEngineFactoryContext | undefined,
|
|
): ContextEngineFactoryContext {
|
|
if (!context) {
|
|
throw new Error("expected context engine factory context");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
function requireRegistryState() {
|
|
const registryState = (globalThis as Record<symbol, unknown>)[
|
|
Symbol.for("openclaw.contextEngineRegistryState")
|
|
] as { engines: Map<string, unknown> } | undefined;
|
|
if (!registryState) {
|
|
throw new Error("expected context engine registry state");
|
|
}
|
|
return registryState;
|
|
}
|
|
|
|
/** A minimal mock engine that satisfies the ContextEngine interface. */
|
|
class MockContextEngine implements ContextEngine {
|
|
readonly info: ContextEngineInfo = {
|
|
id: "mock",
|
|
name: "Mock Engine",
|
|
version: "0.0.1",
|
|
};
|
|
|
|
async ingest(_params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
message: AgentMessage;
|
|
isHeartbeat?: boolean;
|
|
}): Promise<IngestResult> {
|
|
return { ingested: true };
|
|
}
|
|
|
|
async assemble(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
messages: AgentMessage[];
|
|
tokenBudget?: number;
|
|
availableTools?: Set<string>;
|
|
citationsMode?: MemoryCitationsMode;
|
|
}): Promise<AssembleResult> {
|
|
return {
|
|
messages: params.messages,
|
|
estimatedTokens: 42,
|
|
systemPromptAddition: "mock system addition",
|
|
};
|
|
}
|
|
|
|
async compact(_params: {
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
agentId?: string;
|
|
sessionTarget?: ContextEngineSessionTarget;
|
|
tokenBudget?: number;
|
|
compactionTarget?: "budget" | "threshold";
|
|
customInstructions?: string;
|
|
runtimeContext?: Record<string, unknown>;
|
|
}): Promise<CompactResult> {
|
|
return {
|
|
ok: true,
|
|
compacted: true,
|
|
reason: "mock compaction",
|
|
result: {
|
|
summary: "mock summary",
|
|
tokensBefore: 100,
|
|
tokensAfter: 50,
|
|
},
|
|
};
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
class LegacySessionKeyStrictEngine implements ContextEngine {
|
|
readonly info: ContextEngineInfo;
|
|
readonly ingestCalls: Array<Record<string, unknown>> = [];
|
|
readonly assembleCalls: Array<Record<string, unknown>> = [];
|
|
readonly compactCalls: Array<Record<string, unknown>> = [];
|
|
readonly maintainCalls: Array<Record<string, unknown>> = [];
|
|
readonly ingestedMessages: AgentMessage[] = [];
|
|
|
|
constructor(engineId = "legacy-sessionkey-strict") {
|
|
this.info = {
|
|
id: engineId,
|
|
name: "Legacy SessionKey Strict Engine",
|
|
};
|
|
}
|
|
|
|
private rejectSessionKey(params: { sessionKey?: string }): void {
|
|
if (Object.hasOwn(params, "sessionKey")) {
|
|
throw new Error("Unrecognized key(s) in object: 'sessionKey'");
|
|
}
|
|
}
|
|
|
|
async ingest(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
message: AgentMessage;
|
|
isHeartbeat?: boolean;
|
|
}): Promise<IngestResult> {
|
|
this.ingestCalls.push({ ...params });
|
|
this.rejectSessionKey(params);
|
|
this.ingestedMessages.push(params.message);
|
|
return { ingested: true };
|
|
}
|
|
|
|
async assemble(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
messages: AgentMessage[];
|
|
tokenBudget?: number;
|
|
availableTools?: Set<string>;
|
|
citationsMode?: MemoryCitationsMode;
|
|
prompt?: string;
|
|
}): Promise<AssembleResult> {
|
|
this.assembleCalls.push({ ...params });
|
|
this.rejectSessionKey(params);
|
|
return {
|
|
messages: params.messages,
|
|
estimatedTokens: 7,
|
|
};
|
|
}
|
|
|
|
async compact(params: {
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
agentId?: string;
|
|
sessionTarget?: ContextEngineSessionTarget;
|
|
tokenBudget?: number;
|
|
compactionTarget?: "budget" | "threshold";
|
|
customInstructions?: string;
|
|
runtimeContext?: Record<string, unknown>;
|
|
}): Promise<CompactResult> {
|
|
this.compactCalls.push({ ...params });
|
|
this.rejectSessionKey(params);
|
|
return {
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
tokensBefore: 50,
|
|
tokensAfter: 25,
|
|
},
|
|
};
|
|
}
|
|
|
|
async maintain(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
sessionFile: string;
|
|
runtimeContext?: Record<string, unknown>;
|
|
}): Promise<ContextEngineMaintenanceResult> {
|
|
this.maintainCalls.push({ ...params });
|
|
this.rejectSessionKey(params);
|
|
return {
|
|
changed: false,
|
|
bytesFreed: 0,
|
|
rewrittenEntries: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
class SessionKeyRuntimeErrorEngine implements ContextEngine {
|
|
readonly info: ContextEngineInfo;
|
|
assembleCalls = 0;
|
|
constructor(
|
|
engineId = "sessionkey-runtime-error",
|
|
private readonly errorMessage = "sessionKey lookup failed",
|
|
) {
|
|
this.info = {
|
|
id: engineId,
|
|
name: "SessionKey Runtime Error Engine",
|
|
};
|
|
}
|
|
|
|
async ingest(_params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
message: AgentMessage;
|
|
isHeartbeat?: boolean;
|
|
}): Promise<IngestResult> {
|
|
return { ingested: true };
|
|
}
|
|
|
|
async assemble(_params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
messages: AgentMessage[];
|
|
tokenBudget?: number;
|
|
}): Promise<AssembleResult> {
|
|
this.assembleCalls += 1;
|
|
throw new Error(this.errorMessage);
|
|
}
|
|
|
|
async compact(_params: {
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
agentId?: string;
|
|
sessionTarget?: ContextEngineSessionTarget;
|
|
tokenBudget?: number;
|
|
compactionTarget?: "budget" | "threshold";
|
|
customInstructions?: string;
|
|
runtimeContext?: Record<string, unknown>;
|
|
}): Promise<CompactResult> {
|
|
return {
|
|
ok: true,
|
|
compacted: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
class LegacyAssembleStrictEngine implements ContextEngine {
|
|
readonly info: ContextEngineInfo;
|
|
readonly assembleCalls: Array<Record<string, unknown>> = [];
|
|
|
|
constructor(engineId = "legacy-assemble-strict") {
|
|
this.info = {
|
|
id: engineId,
|
|
name: "Legacy Assemble Strict Engine",
|
|
};
|
|
}
|
|
|
|
async ingest(_params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
message: AgentMessage;
|
|
isHeartbeat?: boolean;
|
|
}): Promise<IngestResult> {
|
|
return { ingested: true };
|
|
}
|
|
|
|
async assemble(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
messages: AgentMessage[];
|
|
tokenBudget?: number;
|
|
availableTools?: Set<string>;
|
|
citationsMode?: MemoryCitationsMode;
|
|
prompt?: string;
|
|
runtimeSettings?: unknown;
|
|
}): Promise<AssembleResult> {
|
|
this.assembleCalls.push({ ...params });
|
|
if (Object.hasOwn(params, "sessionKey")) {
|
|
throw new Error("Unrecognized key(s) in object: 'sessionKey'");
|
|
}
|
|
if (Object.hasOwn(params, "prompt")) {
|
|
throw new Error("Unrecognized key(s) in object: 'prompt'");
|
|
}
|
|
if (Object.hasOwn(params, "runtimeSettings")) {
|
|
throw new Error("Unrecognized key(s) in object: 'runtimeSettings'");
|
|
}
|
|
return {
|
|
messages: params.messages,
|
|
estimatedTokens: 3,
|
|
};
|
|
}
|
|
|
|
async compact(_params: {
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
agentId?: string;
|
|
sessionTarget?: ContextEngineSessionTarget;
|
|
tokenBudget?: number;
|
|
compactionTarget?: "budget" | "threshold";
|
|
customInstructions?: string;
|
|
runtimeContext?: Record<string, unknown>;
|
|
}): Promise<CompactResult> {
|
|
return {
|
|
ok: true,
|
|
compacted: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
class LegacyRuntimeSettingsStrictEngine implements ContextEngine {
|
|
readonly info: ContextEngineInfo;
|
|
readonly bootstrapCalls: Array<Record<string, unknown>> = [];
|
|
readonly maintainCalls: Array<Record<string, unknown>> = [];
|
|
readonly afterTurnCalls: Array<Record<string, unknown>> = [];
|
|
readonly assembleCalls: Array<Record<string, unknown>> = [];
|
|
readonly compactCalls: Array<Record<string, unknown>> = [];
|
|
|
|
constructor(engineId = "legacy-runtime-settings-strict") {
|
|
this.info = {
|
|
id: engineId,
|
|
name: "Legacy Runtime Settings Strict Engine",
|
|
};
|
|
}
|
|
|
|
private rejectLegacyCompatFields(params: Record<string, unknown>): void {
|
|
if (Object.hasOwn(params, "runtimeSettings")) {
|
|
throw new Error("Unrecognized key(s) in object: 'runtimeSettings'");
|
|
}
|
|
if (Object.hasOwn(params, "sessionTarget")) {
|
|
throw new Error("Unrecognized key(s) in object: 'sessionTarget'");
|
|
}
|
|
if (Object.hasOwn(params, "runtimeContext")) {
|
|
throw new Error("Unrecognized key(s) in object: 'runtimeContext'");
|
|
}
|
|
}
|
|
|
|
async bootstrap(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
sessionFile: string;
|
|
runtimeSettings?: unknown;
|
|
sessionTarget?: ContextEngineSessionTarget;
|
|
runtimeContext?: unknown;
|
|
}): Promise<BootstrapResult> {
|
|
this.bootstrapCalls.push({ ...params });
|
|
this.rejectLegacyCompatFields(params);
|
|
return { bootstrapped: true };
|
|
}
|
|
|
|
async ingest(_params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
message: AgentMessage;
|
|
isHeartbeat?: boolean;
|
|
}): Promise<IngestResult> {
|
|
return { ingested: true };
|
|
}
|
|
|
|
async maintain(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
sessionFile: string;
|
|
runtimeSettings?: unknown;
|
|
sessionTarget?: ContextEngineSessionTarget;
|
|
runtimeContext?: unknown;
|
|
}): Promise<ContextEngineMaintenanceResult> {
|
|
this.maintainCalls.push({ ...params });
|
|
this.rejectLegacyCompatFields(params);
|
|
return { changed: false, bytesFreed: 0, rewrittenEntries: 0 };
|
|
}
|
|
|
|
async afterTurn(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
sessionFile: string;
|
|
messages: AgentMessage[];
|
|
prePromptMessageCount: number;
|
|
runtimeSettings?: unknown;
|
|
sessionTarget?: ContextEngineSessionTarget;
|
|
runtimeContext?: unknown;
|
|
}): Promise<void> {
|
|
this.afterTurnCalls.push({ ...params });
|
|
this.rejectLegacyCompatFields(params);
|
|
}
|
|
|
|
async assemble(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
messages: AgentMessage[];
|
|
runtimeSettings?: unknown;
|
|
}): Promise<AssembleResult> {
|
|
this.assembleCalls.push({ ...params });
|
|
this.rejectLegacyCompatFields(params);
|
|
return { messages: params.messages, estimatedTokens: 3 };
|
|
}
|
|
|
|
async compact(params: {
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
agentId?: string;
|
|
sessionTarget?: ContextEngineSessionTarget;
|
|
runtimeSettings?: unknown;
|
|
runtimeContext?: unknown;
|
|
}): Promise<CompactResult> {
|
|
this.compactCalls.push({ ...params });
|
|
this.rejectLegacyCompatFields(params);
|
|
return { ok: true, compacted: false };
|
|
}
|
|
}
|
|
|
|
class LegacyRuntimeThenAssembleStrictEngine implements ContextEngine {
|
|
readonly info: ContextEngineInfo;
|
|
readonly maintainCalls: Array<Record<string, unknown>> = [];
|
|
readonly assembleCalls: Array<Record<string, unknown>> = [];
|
|
|
|
constructor(engineId = "legacy-runtime-then-assemble-strict") {
|
|
this.info = {
|
|
id: engineId,
|
|
name: "Legacy Runtime Then Assemble Strict Engine",
|
|
};
|
|
}
|
|
|
|
async ingest(_params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
message: AgentMessage;
|
|
isHeartbeat?: boolean;
|
|
}): Promise<IngestResult> {
|
|
return { ingested: true };
|
|
}
|
|
|
|
async maintain(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
sessionFile: string;
|
|
runtimeSettings?: unknown;
|
|
}): Promise<ContextEngineMaintenanceResult> {
|
|
this.maintainCalls.push({ ...params });
|
|
if (Object.hasOwn(params, "runtimeSettings")) {
|
|
throw new Error("Unrecognized key(s) in object: 'runtimeSettings'");
|
|
}
|
|
return { changed: false, bytesFreed: 0, rewrittenEntries: 0 };
|
|
}
|
|
|
|
async assemble(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
messages: AgentMessage[];
|
|
prompt?: string;
|
|
runtimeSettings?: unknown;
|
|
}): Promise<AssembleResult> {
|
|
this.assembleCalls.push({ ...params });
|
|
if (Object.hasOwn(params, "sessionKey")) {
|
|
throw new Error("Unrecognized key(s) in object: 'sessionKey'");
|
|
}
|
|
if (Object.hasOwn(params, "prompt")) {
|
|
throw new Error("Unrecognized key(s) in object: 'prompt'");
|
|
}
|
|
if (Object.hasOwn(params, "runtimeSettings")) {
|
|
throw new Error("Unrecognized key(s) in object: 'runtimeSettings'");
|
|
}
|
|
return { messages: params.messages, estimatedTokens: 5 };
|
|
}
|
|
|
|
async compact(_params: {
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
agentId?: string;
|
|
sessionTarget?: ContextEngineSessionTarget;
|
|
}): Promise<CompactResult> {
|
|
return { ok: true, compacted: false };
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 1. Engine contract tests
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Engine contract tests", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
compactEmbeddedAgentSessionDirectMock.mockReset();
|
|
clearMemoryPluginState();
|
|
});
|
|
|
|
it("a mock engine implementing ContextEngine can be registered and resolved", async () => {
|
|
const factory = () => new MockContextEngine();
|
|
registerTestContextEngine("mock", factory);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot("mock"));
|
|
expect(engine).toBeInstanceOf(MockContextEngine);
|
|
expect(engine.info.id).toBe("mock");
|
|
});
|
|
|
|
it("legacy compact preserves runtimeContext currentTokenCount when top-level value is absent", async () => {
|
|
const compactRuntimeSpy = installCompactRuntimeSpy();
|
|
const engine = new LegacyContextEngine();
|
|
|
|
await engine.compact({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:s1",
|
|
sessionTarget: { agentId: "main", sessionId: "s1", sessionKey: "agent:main:s1" },
|
|
runtimeContext: {
|
|
workspaceDir: "/tmp/workspace",
|
|
currentTokenCount: 277403,
|
|
},
|
|
});
|
|
|
|
expect(compactRuntimeSpy).toHaveBeenCalledTimes(1);
|
|
expect(requireCompactRuntimeParams(0).currentTokenCount).toBe(277403);
|
|
});
|
|
|
|
it("delegateCompactionToRuntime reuses the legacy runtime bridge", async () => {
|
|
const compactRuntimeSpy = installCompactRuntimeSpy();
|
|
const sessionTarget = {
|
|
agentId: "main",
|
|
sessionId: "s2",
|
|
sessionKey: "agent:main:s2",
|
|
storePath: "/tmp/openclaw-agent.sqlite",
|
|
};
|
|
const result = await delegateCompactionToRuntime({
|
|
sessionId: "s2",
|
|
sessionKey: "agent:main:s2",
|
|
sessionTarget,
|
|
tokenBudget: 4096,
|
|
runtimeContext: {
|
|
workspaceDir: "/tmp/workspace",
|
|
currentTokenCount: 12345,
|
|
},
|
|
});
|
|
|
|
expect(compactRuntimeSpy).toHaveBeenCalledTimes(1);
|
|
const compactRuntimeParams = requireCompactRuntimeParams(0);
|
|
expect(compactRuntimeParams.sessionId).toBe("s2");
|
|
expect(compactRuntimeParams.sessionKey).toBe("agent:main:s2");
|
|
expect(compactRuntimeParams.sessionTarget).toEqual(sessionTarget);
|
|
expect(compactRuntimeParams).not.toHaveProperty("sessionFile");
|
|
expect(compactRuntimeParams.tokenBudget).toBe(4096);
|
|
expect(compactRuntimeParams.currentTokenCount).toBe(12345);
|
|
expect(compactRuntimeParams.workspaceDir).toBe("/tmp/workspace");
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
compacted: false,
|
|
reason: "mock compaction",
|
|
result: {
|
|
summary: "",
|
|
firstKeptEntryId: "",
|
|
tokensBefore: 0,
|
|
tokensAfter: 0,
|
|
details: undefined,
|
|
sessionTarget,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("delegateCompactionToRuntime returns successor sessionTarget without sessionFile", async () => {
|
|
compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
compacted: true,
|
|
reason: undefined,
|
|
result: {
|
|
summary: "summary",
|
|
firstKeptEntryId: "entry-1",
|
|
tokensBefore: 100,
|
|
tokensAfter: 40,
|
|
details: undefined,
|
|
sessionId: "s3-successor",
|
|
sessionFile: "sqlite:main:s3-successor:/tmp/openclaw-agent.sqlite",
|
|
},
|
|
});
|
|
|
|
const result = await delegateCompactionToRuntime({
|
|
sessionId: "s3",
|
|
sessionKey: "agent:main:s3",
|
|
tokenBudget: 4096,
|
|
runtimeContext: {
|
|
workspaceDir: "/tmp/workspace",
|
|
},
|
|
});
|
|
|
|
expect(result.result).toMatchObject({
|
|
sessionId: "s3-successor",
|
|
sessionTarget: {
|
|
agentId: "main",
|
|
sessionId: "s3-successor",
|
|
sessionKey: "agent:main:s3",
|
|
storePath: "/tmp/openclaw-agent.sqlite",
|
|
},
|
|
});
|
|
expect(result.result).not.toHaveProperty("sessionFile");
|
|
});
|
|
|
|
it("allows the caller key to rebind to a legacy successor session", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-successor-"));
|
|
const storePath = path.join(root, "agents", "main", "sessions", "sessions.json");
|
|
const sessionKey = "agent:main:successor";
|
|
try {
|
|
await upsertSessionEntry(
|
|
{ agentId: "main", sessionKey, storePath },
|
|
{ sessionId: "before-compaction", updatedAt: 1 },
|
|
);
|
|
await upsertSessionEntry(
|
|
{ agentId: "main", sessionKey: "agent:main:aaa-successor-alias", storePath },
|
|
{ sessionId: "after-compaction", updatedAt: 2 },
|
|
);
|
|
compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
compacted: true,
|
|
reason: undefined,
|
|
result: {
|
|
summary: "summary",
|
|
firstKeptEntryId: "entry-1",
|
|
tokensBefore: 100,
|
|
tokensAfter: 40,
|
|
details: undefined,
|
|
sessionId: "after-compaction",
|
|
sessionFile: `sqlite:main:after-compaction:${storePath}`,
|
|
},
|
|
});
|
|
|
|
const result = await delegateCompactionToRuntime({
|
|
agentId: "main",
|
|
sessionId: "before-compaction",
|
|
sessionKey,
|
|
tokenBudget: 4096,
|
|
});
|
|
|
|
expect(result.result?.sessionTarget).toMatchObject({
|
|
agentId: "main",
|
|
sessionId: "after-compaction",
|
|
sessionKey,
|
|
storePath,
|
|
});
|
|
} finally {
|
|
closeOpenClawAgentDatabasesForTest();
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects a structured successor key from another agent", async () => {
|
|
installCompactRuntimeSpy();
|
|
|
|
await expect(
|
|
delegateCompactionToRuntime({
|
|
sessionId: "s-agent-conflict",
|
|
sessionKey: "agent:main:s-agent-conflict",
|
|
sessionTarget: {
|
|
agentId: "worker",
|
|
sessionId: "s-agent-conflict",
|
|
sessionKey: "agent:main:s-agent-conflict",
|
|
storePath: "/tmp/openclaw-agent.sqlite",
|
|
},
|
|
tokenBudget: 4096,
|
|
}),
|
|
).rejects.toThrow("successor target conflicts with the caller session identity");
|
|
});
|
|
|
|
it("rejects a successor marker that changes the caller store", async () => {
|
|
compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
tokensBefore: 100,
|
|
sessionId: "store-redirect-successor",
|
|
sessionFile: "sqlite:main:store-redirect-successor:/tmp/other.sqlite",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
delegateCompactionToRuntime({
|
|
sessionId: "store-redirect-source",
|
|
sessionKey: "agent:main:store-redirect",
|
|
sessionTarget: {
|
|
agentId: "main",
|
|
sessionId: "store-redirect-source",
|
|
sessionKey: "agent:main:store-redirect",
|
|
storePath: "/tmp/caller.sqlite",
|
|
},
|
|
}),
|
|
).rejects.toThrow("successor target conflicts with the caller session identity");
|
|
});
|
|
|
|
it("rejects contradictory marker and top-level successor identities", async () => {
|
|
compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
tokensBefore: 100,
|
|
sessionId: "top-level-successor",
|
|
sessionFile: "sqlite:main:marker-successor:/tmp/openclaw-agent.sqlite",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
delegateCompactionToRuntime({
|
|
sessionId: "source-session",
|
|
sessionKey: "agent:main:successor-conflict",
|
|
}),
|
|
).rejects.toThrow("successor identity is inconsistent");
|
|
});
|
|
|
|
it("rejects an internally consistent successor for another caller agent", async () => {
|
|
installCompactRuntimeSpy();
|
|
|
|
await expect(
|
|
delegateCompactionToRuntime({
|
|
agentId: "main",
|
|
sessionId: "s-agent-redirect",
|
|
sessionKey: "agent:main:s-agent-redirect",
|
|
sessionTarget: {
|
|
agentId: "worker",
|
|
sessionId: "s-agent-redirect",
|
|
sessionKey: "agent:worker:s-agent-redirect",
|
|
},
|
|
tokenBudget: 4096,
|
|
}),
|
|
).rejects.toThrow("successor target conflicts with the caller session identity");
|
|
});
|
|
|
|
it("rejects a legacy successor marker for another caller agent", async () => {
|
|
compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
compacted: true,
|
|
reason: undefined,
|
|
result: {
|
|
summary: "summary",
|
|
firstKeptEntryId: "entry-1",
|
|
tokensBefore: 100,
|
|
tokensAfter: 40,
|
|
details: undefined,
|
|
sessionId: "worker-successor",
|
|
sessionFile: "sqlite:worker:worker-successor:/tmp/worker-sessions.json",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
delegateCompactionToRuntime({
|
|
agentId: "main",
|
|
sessionId: "main-session",
|
|
sessionKey: "global",
|
|
tokenBudget: 4096,
|
|
}),
|
|
).rejects.toThrow("successor target conflicts with the caller session identity");
|
|
});
|
|
|
|
it("delegateCompactionToRuntime forwards the caller abortSignal to the runtime (#89868)", async () => {
|
|
installCompactRuntimeSpy();
|
|
const controller = new AbortController();
|
|
await delegateCompactionToRuntime({
|
|
sessionId: "s-abort",
|
|
sessionKey: "agent:main:s-abort",
|
|
tokenBudget: 4096,
|
|
abortSignal: controller.signal,
|
|
});
|
|
|
|
const compactRuntimeParams = requireCompactRuntimeParams(0);
|
|
expect(compactRuntimeParams.abortSignal).toBe(controller.signal);
|
|
});
|
|
|
|
it("delegateCompactionToRuntime passes undefined abortSignal when none supplied", async () => {
|
|
installCompactRuntimeSpy();
|
|
await delegateCompactionToRuntime({
|
|
sessionId: "s-no-abort",
|
|
sessionKey: "agent:main:s-no-abort",
|
|
tokenBudget: 4096,
|
|
});
|
|
|
|
const compactRuntimeParams = requireCompactRuntimeParams(0);
|
|
expect(compactRuntimeParams.abortSignal).toBeUndefined();
|
|
});
|
|
|
|
it("builds a normalized memory system prompt addition from the active memory prompt path", () => {
|
|
registerTestMemoryPromptBuilder(({ citationsMode }) => [
|
|
"## Memory Recall",
|
|
`citations=${citationsMode ?? "auto"}`,
|
|
"",
|
|
]);
|
|
|
|
expect(
|
|
buildMemorySystemPromptAddition({
|
|
availableTools: new Set(["memory_search"]),
|
|
citationsMode: "off",
|
|
}),
|
|
).toBe("## Memory Recall\ncitations=off");
|
|
});
|
|
|
|
it("passes agent context through delegated memory prompt assembly", () => {
|
|
registerTestMemoryPromptBuilder(({ agentId, agentSessionKey, sandboxed }) => [
|
|
"## Agent Memory",
|
|
`agent=${agentId} session=${agentSessionKey} sandboxed=${sandboxed}`,
|
|
"",
|
|
]);
|
|
|
|
expect(
|
|
buildMemorySystemPromptAddition({
|
|
availableTools: new Set(["memory_search", "memory_get"]),
|
|
agentId: "marketing-agent",
|
|
agentSessionKey: "agent:marketing-agent:main",
|
|
sandboxed: true,
|
|
}),
|
|
).toBe(
|
|
"## Agent Memory\nagent=marketing-agent session=agent:marketing-agent:main sandboxed=true",
|
|
);
|
|
});
|
|
|
|
it("returns undefined when the active memory prompt path contributes nothing", () => {
|
|
expect(
|
|
buildMemorySystemPromptAddition({
|
|
availableTools: new Set(["memory_search"]),
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("prepares async memory state before context-engine prompt rendering", async () => {
|
|
const prepare = vi.fn(async () => ["## Prepared Memory", "loaded from sqlite", ""]);
|
|
registerMemoryPromptPreparation("memory-wiki", prepare);
|
|
|
|
await expect(
|
|
prepareMemorySystemPromptAddition({
|
|
availableTools: new Set(["wiki_search"]),
|
|
agentId: "main",
|
|
agentSessionKey: "agent:main:main",
|
|
}),
|
|
).resolves.toBe("## Prepared Memory\nloaded from sqlite");
|
|
expect(prepare).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 2. Registry tests
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Registry tests", () => {
|
|
it("registerTestContextEngine() stores retrievable factories", () => {
|
|
const factory = () => new MockContextEngine();
|
|
registerTestContextEngine("reg-test-2", factory);
|
|
|
|
expect(getContextEngineRegistration("reg-test-2")?.factory).toBe(factory);
|
|
});
|
|
|
|
it("tracks all registered ids", () => {
|
|
registerTestContextEngine("reg-test-a", () => new MockContextEngine());
|
|
registerTestContextEngine("reg-test-b", () => new MockContextEngine());
|
|
|
|
expect(getContextEngineRegistration("reg-test-a")).toBeDefined();
|
|
expect(getContextEngineRegistration("reg-test-b")).toBeDefined();
|
|
});
|
|
|
|
it("registering the same id with the same owner refreshes the factory", () => {
|
|
const factory1 = () => new MockContextEngine();
|
|
const factory2 = () => new MockContextEngine();
|
|
|
|
expect(
|
|
registerContextEngineForOwner("reg-overwrite", factory1, "owner-a", {
|
|
allowSameOwnerRefresh: true,
|
|
}),
|
|
).toEqual({ ok: true });
|
|
expect(getContextEngineRegistration("reg-overwrite")?.factory).toBe(factory1);
|
|
|
|
expect(
|
|
registerContextEngineForOwner("reg-overwrite", factory2, "owner-a", {
|
|
allowSameOwnerRefresh: true,
|
|
}),
|
|
).toEqual({ ok: true });
|
|
expect(getContextEngineRegistration("reg-overwrite")?.factory).toBe(factory2);
|
|
expect(getContextEngineRegistration("reg-overwrite")?.factory).not.toBe(factory1);
|
|
});
|
|
|
|
it("rejects context engine registrations from a different owner", () => {
|
|
const factory1 = () => new MockContextEngine();
|
|
const factory2 = () => new MockContextEngine();
|
|
|
|
expect(
|
|
registerContextEngineForOwner("reg-owner-guard", factory1, "owner-a", {
|
|
allowSameOwnerRefresh: true,
|
|
}),
|
|
).toEqual({ ok: true });
|
|
expect(registerContextEngineForOwner("reg-owner-guard", factory2, "owner-b")).toEqual({
|
|
ok: false,
|
|
existingOwner: "owner-a",
|
|
});
|
|
expect(getContextEngineRegistration("reg-owner-guard")?.factory).toBe(factory1);
|
|
});
|
|
|
|
it("exposes the trusted plugin owner for a resolved registered engine", async () => {
|
|
const engineId = `owner-policy-${Date.now().toString(36)}`;
|
|
registerContextEngineForOwner(engineId, () => new MockContextEngine(), "plugin:lossless-claw", {
|
|
allowSameOwnerRefresh: true,
|
|
});
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
expect(resolveContextEngineOwnerPluginId(engine)).toBe("lossless-claw");
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 3. Default engine selection
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Legacy sessionKey compatibility", () => {
|
|
beforeEach(() => {
|
|
registerLegacyContextEngine();
|
|
resetContextEngineRuntimeQuarantineForTests();
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("memoizes legacy mode after the first strict compatibility retry", async () => {
|
|
const engineId = `legacy-sessionkey-${Date.now().toString(36)}`;
|
|
const strictEngine = new LegacySessionKeyStrictEngine(engineId);
|
|
registerTestContextEngine(engineId, () => strictEngine);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
const firstAssembled = await engine.assemble({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
messages: [makeMockMessage()],
|
|
});
|
|
const compacted = await engine.compact({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
});
|
|
|
|
expect(firstAssembled.estimatedTokens).toBe(7);
|
|
expect(compacted.compacted).toBe(true);
|
|
expect(strictEngine.assembleCalls).toHaveLength(2);
|
|
expect(strictEngine.assembleCalls[0]).toHaveProperty("sessionKey", "agent:main:test");
|
|
expect(strictEngine.assembleCalls[1]).not.toHaveProperty("sessionKey");
|
|
expect(strictEngine.compactCalls).toHaveLength(1);
|
|
expect(strictEngine.compactCalls[0]).not.toHaveProperty("sessionKey");
|
|
});
|
|
|
|
it("retries strict ingest once and ingests each message only once", async () => {
|
|
const engineId = `legacy-sessionkey-ingest-${Date.now().toString(36)}`;
|
|
const strictEngine = new LegacySessionKeyStrictEngine(engineId);
|
|
registerTestContextEngine(engineId, () => strictEngine);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
const firstMessage = makeMockMessage("user", "first");
|
|
const secondMessage = makeMockMessage("assistant", "second");
|
|
|
|
await engine.ingest({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
message: firstMessage,
|
|
});
|
|
await engine.ingest({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
message: secondMessage,
|
|
});
|
|
|
|
expect(strictEngine.ingestCalls).toHaveLength(3);
|
|
expect(strictEngine.ingestCalls[0]).toHaveProperty("sessionKey", "agent:main:test");
|
|
expect(strictEngine.ingestCalls[1]).not.toHaveProperty("sessionKey");
|
|
expect(strictEngine.ingestCalls[2]).not.toHaveProperty("sessionKey");
|
|
expect(strictEngine.ingestedMessages).toEqual([firstMessage, secondMessage]);
|
|
});
|
|
|
|
it("retries strict maintain once and memoizes legacy mode there too", async () => {
|
|
const engineId = `legacy-sessionkey-maintain-${Date.now().toString(36)}`;
|
|
const strictEngine = new LegacySessionKeyStrictEngine(engineId);
|
|
registerTestContextEngine(engineId, () => strictEngine);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
await engine.maintain?.({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
sessionFile: "/tmp/session.json",
|
|
});
|
|
|
|
expect(strictEngine.maintainCalls).toHaveLength(2);
|
|
expect(strictEngine.maintainCalls[0]).toHaveProperty("sessionKey", "agent:main:test");
|
|
expect(strictEngine.maintainCalls[1]).not.toHaveProperty("sessionKey");
|
|
});
|
|
|
|
it("keeps learning rejected legacy keys after runtimeSettings marks an engine legacy", async () => {
|
|
const engineId = uniqueEngineId("legacy-runtime-then-assemble");
|
|
const strictEngine = new LegacyRuntimeThenAssembleStrictEngine(engineId);
|
|
registerTestContextEngine(engineId, () => strictEngine);
|
|
|
|
const runtimeSettings = { schemaVersion: 1 } as never;
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
await engine.maintain?.({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
sessionFile: "/tmp/session.json",
|
|
runtimeSettings,
|
|
});
|
|
const assembled = await engine.assemble({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
messages: [makeMockMessage("user", "hello")],
|
|
prompt: "hello",
|
|
runtimeSettings,
|
|
});
|
|
|
|
expect(assembled.estimatedTokens).toBe(5);
|
|
expect(strictEngine.maintainCalls).toHaveLength(2);
|
|
expect(strictEngine.maintainCalls[0]).toHaveProperty("runtimeSettings");
|
|
expect(strictEngine.maintainCalls[1]).not.toHaveProperty("runtimeSettings");
|
|
expect(strictEngine.assembleCalls).toHaveLength(3);
|
|
expect(strictEngine.assembleCalls[0]).not.toHaveProperty("runtimeSettings");
|
|
expect(strictEngine.assembleCalls[0]).toHaveProperty("sessionKey", "agent:main:test");
|
|
expect(strictEngine.assembleCalls[0]).toHaveProperty("prompt", "hello");
|
|
expect(strictEngine.assembleCalls[1]).not.toHaveProperty("runtimeSettings");
|
|
expect(strictEngine.assembleCalls[1]).not.toHaveProperty("sessionKey");
|
|
expect(strictEngine.assembleCalls[1]).toHaveProperty("prompt", "hello");
|
|
expect(strictEngine.assembleCalls[2]).not.toHaveProperty("runtimeSettings");
|
|
expect(strictEngine.assembleCalls[2]).not.toHaveProperty("sessionKey");
|
|
expect(strictEngine.assembleCalls[2]).not.toHaveProperty("prompt");
|
|
});
|
|
|
|
it("quarantines and falls back for non-compat runtime errors", async () => {
|
|
const engineId = `sessionkey-runtime-${Date.now().toString(36)}`;
|
|
const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(engineId);
|
|
registerTestContextEngine(engineId, () => runtimeErrorEngine);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
const message = makeMockMessage();
|
|
|
|
const result = await engine.assemble({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
messages: [message],
|
|
});
|
|
const nextEngine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
expect(result.messages).toEqual([message]);
|
|
expect(nextEngine.info.id).toBe("legacy");
|
|
expect(runtimeErrorEngine.assembleCalls).toBe(1);
|
|
expect(listContextEngineQuarantines()).toEqual([
|
|
expect.objectContaining({
|
|
engineId,
|
|
operation: "assemble",
|
|
reason: "sessionKey lookup failed",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("quarantines 'Unknown sessionKey' runtime failures instead of treating them as schema compat", async () => {
|
|
const engineId = `sessionkey-unknown-runtime-${Date.now().toString(36)}`;
|
|
const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine(
|
|
engineId,
|
|
'Unknown sessionKey "agent:main:missing"',
|
|
);
|
|
registerTestContextEngine(engineId, () => runtimeErrorEngine);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
const message = makeMockMessage();
|
|
|
|
const result = await engine.assemble({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:missing",
|
|
messages: [message],
|
|
});
|
|
|
|
expect(result.messages).toEqual([message]);
|
|
expect(runtimeErrorEngine.assembleCalls).toBe(1);
|
|
expect(listContextEngineQuarantines()).toEqual([
|
|
expect.objectContaining({
|
|
engineId,
|
|
operation: "assemble",
|
|
reason: 'Unknown sessionKey "agent:main:missing"',
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("Default engine selection", () => {
|
|
// Ensure both legacy and a custom test engine are registered before these tests.
|
|
beforeEach(() => {
|
|
// Registration is idempotent (Map.set), so calling again is safe.
|
|
registerLegacyContextEngine();
|
|
// Register a lightweight custom stub so we don't need external resources.
|
|
registerTestContextEngine("test-engine", () => {
|
|
const engine: ContextEngine = {
|
|
info: { id: "test-engine", name: "Custom Test Engine", version: "0.0.0" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
};
|
|
return engine;
|
|
});
|
|
});
|
|
|
|
it("resolveContextEngine() with no config returns the default ('legacy') engine", async () => {
|
|
const engine = await resolveContextEngine();
|
|
expect(engine.info.id).toBe("legacy");
|
|
});
|
|
|
|
it("resolveContextEngine() with config contextEngine='legacy' returns legacy engine", async () => {
|
|
const engine = await resolveContextEngine(configWithSlot("legacy"));
|
|
expect(engine.info.id).toBe("legacy");
|
|
});
|
|
|
|
it("resolveContextEngine() with config contextEngine='test-engine' returns the custom engine", async () => {
|
|
const engine = await resolveContextEngine(configWithSlot("test-engine"));
|
|
expect(engine.info.id).toBe("test-engine");
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 3b. Factory context passing
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Factory context passing", () => {
|
|
it("passes ContextEngineFactoryContext to factories that accept a parameter", async () => {
|
|
const engineId = `factory-ctx-${Date.now().toString(36)}`;
|
|
let receivedCtx: ContextEngineFactoryContext | undefined;
|
|
|
|
const factory: ContextEngineFactory = (ctx: ContextEngineFactoryContext) => {
|
|
receivedCtx = ctx;
|
|
return {
|
|
info: { id: engineId, name: "Ctx Engine" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
};
|
|
};
|
|
registerTestContextEngine(engineId, factory);
|
|
|
|
const cfg = configWithSlot(engineId);
|
|
await resolveContextEngine(cfg, {
|
|
agentDir: "/tmp/agent",
|
|
workspaceDir: "/tmp/workspace",
|
|
});
|
|
|
|
const context = requireFactoryContext(receivedCtx);
|
|
expect(context.config).toBe(cfg);
|
|
expect(context.agentDir).toBe("/tmp/agent");
|
|
expect(context.workspaceDir).toBe("/tmp/workspace");
|
|
});
|
|
|
|
it("no-arg factories still work when context is passed", async () => {
|
|
const engineId = `factory-noarg-${Date.now().toString(36)}`;
|
|
let called = false;
|
|
|
|
const factory: ContextEngineFactory = () => {
|
|
called = true;
|
|
return {
|
|
info: { id: engineId, name: "No-Arg Engine" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
};
|
|
};
|
|
registerTestContextEngine(engineId, factory);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId), {
|
|
agentDir: "/tmp/agent",
|
|
workspaceDir: "/tmp/workspace",
|
|
});
|
|
|
|
expect(called).toBe(true);
|
|
expect(engine.info.id).toBe(engineId);
|
|
});
|
|
|
|
it("passes undefined config when resolveContextEngine is called without config", async () => {
|
|
let receivedCtx: ContextEngineFactoryContext | undefined;
|
|
|
|
// Override the default "legacy" engine to intercept the no-config path
|
|
registerContextEngineForOwner(
|
|
"legacy",
|
|
(ctx: ContextEngineFactoryContext) => {
|
|
receivedCtx = ctx;
|
|
return {
|
|
info: { id: "legacy", name: "NoConfig Engine", version: "1" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
};
|
|
},
|
|
"core",
|
|
{ allowSameOwnerRefresh: true },
|
|
);
|
|
|
|
await resolveContextEngine(undefined);
|
|
|
|
const context = requireFactoryContext(receivedCtx);
|
|
expect(context.config).toBeUndefined();
|
|
expect(context.agentDir).toBeUndefined();
|
|
expect(context.workspaceDir).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("Read-only plugin discovery registrations", () => {
|
|
beforeEach(() => {
|
|
registerLegacyContextEngine();
|
|
resetContextEngineRuntimeQuarantineForTests();
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("does not construct or quarantine read-only discovery context-engine factories", async () => {
|
|
const engineId = uniqueEngineId("lossless-readonly");
|
|
const owner = "plugin:lossless-claw";
|
|
let readOnlyFactoryCalls = 0;
|
|
let runtimeFactoryCalls = 0;
|
|
|
|
registerContextEngineForOwner(
|
|
engineId,
|
|
() => {
|
|
readOnlyFactoryCalls += 1;
|
|
throw new Error("Engine initialization is disabled during read-only plugin registration");
|
|
},
|
|
owner,
|
|
{ allowSameOwnerRefresh: true, lifecycle: "readOnlyDiscovery" },
|
|
);
|
|
|
|
const discoveryFallback = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
expect(discoveryFallback.info.id).toBe("legacy");
|
|
expect(readOnlyFactoryCalls).toBe(0);
|
|
expect(listContextEngineQuarantines().some((entry) => entry.engineId === engineId)).toBe(false);
|
|
expect(console.warn).toHaveBeenCalledWith(
|
|
`[context-engine] Context engine "${engineId}" owner=${owner} is registered for read-only discovery only; falling back to default engine "legacy" without quarantine until runtime activation registers it.`,
|
|
);
|
|
|
|
registerContextEngineForOwner(
|
|
engineId,
|
|
() => {
|
|
runtimeFactoryCalls += 1;
|
|
return {
|
|
info: { id: "lossless-claw", name: "Lossless Claw" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
} satisfies ContextEngine;
|
|
},
|
|
owner,
|
|
{ allowSameOwnerRefresh: true, lifecycle: "runtime" },
|
|
);
|
|
|
|
const runtimeEngine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
expect(runtimeEngine.info.id).toBe("lossless-claw");
|
|
expect(readOnlyFactoryCalls).toBe(0);
|
|
expect(runtimeFactoryCalls).toBe(1);
|
|
expect(listContextEngineQuarantines().some((entry) => entry.engineId === engineId)).toBe(false);
|
|
|
|
registerContextEngineForOwner(
|
|
engineId,
|
|
() => {
|
|
readOnlyFactoryCalls += 1;
|
|
throw new Error("read-only discovery should not replace runtime registration");
|
|
},
|
|
owner,
|
|
{ allowSameOwnerRefresh: true, lifecycle: "readOnlyDiscovery" },
|
|
);
|
|
|
|
const stillRuntimeEngine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
expect(stillRuntimeEngine.info.id).toBe("lossless-claw");
|
|
expect(readOnlyFactoryCalls).toBe(0);
|
|
expect(runtimeFactoryCalls).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 4. Invalid engine fallback
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Invalid engine fallback", () => {
|
|
beforeEach(() => {
|
|
registerLegacyContextEngine();
|
|
resetContextEngineRuntimeQuarantineForTests();
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("falls back to default engine for missing or invalid requested engines", async () => {
|
|
const cases = [
|
|
{
|
|
name: "missing registration",
|
|
engineId: uniqueEngineId("does-not-exist"),
|
|
register: () => undefined,
|
|
expectedError: (engineId: string) =>
|
|
`[context-engine] Context engine "${engineId}" failed during resolve: not registered; quarantining it for this process and falling back to default engine "legacy".`,
|
|
},
|
|
{
|
|
name: "factory throws",
|
|
engineId: uniqueEngineId("factory-throw"),
|
|
register: (engineId: string) => {
|
|
registerTestContextEngine(engineId, () => {
|
|
throw new Error("plugin version mismatch");
|
|
});
|
|
},
|
|
expectedError: (engineId: string) =>
|
|
`[context-engine] Context engine "${engineId}" owner=test:${engineId} failed during factory: plugin version mismatch; quarantining it for this process and falling back to default engine "legacy".`,
|
|
},
|
|
{
|
|
name: "missing info metadata",
|
|
engineId: uniqueEngineId("invalid-info"),
|
|
register: (engineId: string) => {
|
|
registerTestContextEngine(
|
|
engineId,
|
|
() =>
|
|
({
|
|
async ingest() {
|
|
return { ingested: false };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
}) as unknown as ContextEngine,
|
|
);
|
|
},
|
|
expectedError: (engineId: string) =>
|
|
`[context-engine] Context engine "${engineId}" owner=test:${engineId} failed during contract-validation: Context engine "${engineId}" factory returned an invalid ContextEngine: missing info.; quarantining it for this process and falling back to default engine "legacy".`,
|
|
},
|
|
{
|
|
name: "missing lifecycle methods",
|
|
engineId: uniqueEngineId("invalid-methods"),
|
|
register: (engineId: string) => {
|
|
registerTestContextEngine(
|
|
engineId,
|
|
() =>
|
|
({
|
|
info: { id: engineId, name: "Broken Engine" },
|
|
async ingest() {
|
|
return { ingested: false };
|
|
},
|
|
}) as unknown as ContextEngine,
|
|
);
|
|
},
|
|
expectedError: (engineId: string) =>
|
|
`[context-engine] Context engine "${engineId}" owner=test:${engineId} failed during contract-validation: Context engine "${engineId}" factory returned an invalid ContextEngine: missing assemble(), missing compact().; quarantining it for this process and falling back to default engine "legacy".`,
|
|
},
|
|
{
|
|
name: "contract validation throws",
|
|
engineId: uniqueEngineId("validation-throw"),
|
|
register: (engineId: string) => {
|
|
registerTestContextEngine(engineId, () => 42n as unknown as ContextEngine);
|
|
},
|
|
expectedError: (engineId: string) =>
|
|
`[context-engine] Context engine "${engineId}" owner=test:${engineId} failed during contract-validation: Do not know how to serialize a BigInt; quarantining it for this process and falling back to default engine "legacy".`,
|
|
},
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
vi.mocked(console.error).mockClear();
|
|
testCase.register(testCase.engineId);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(testCase.engineId));
|
|
|
|
expect(engine.info.id, testCase.name).toBe("legacy");
|
|
expect(console.error, testCase.name).toHaveBeenCalledWith(
|
|
testCase.expectedError(testCase.engineId),
|
|
);
|
|
expect(
|
|
listContextEngineQuarantines().some((entry) => entry.engineId === testCase.engineId),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("quarantines a selected engine after lifecycle failure and resolves legacy next time", async () => {
|
|
const engineId = uniqueEngineId("runtime-fail");
|
|
const assemble = vi.fn(async () => {
|
|
throw new Error("lcm db is corrupt");
|
|
});
|
|
let factoryCalls = 0;
|
|
registerTestContextEngine(engineId, () => {
|
|
factoryCalls += 1;
|
|
return {
|
|
info: { id: "lcm", name: "Lossless Context Manager" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
assemble,
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
};
|
|
});
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
const message = makeMockMessage("user", "hello");
|
|
const result = await engine.assemble({
|
|
sessionId: "s1",
|
|
messages: [message],
|
|
});
|
|
const nextEngine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
expect(result.messages).toEqual([message]);
|
|
expect(nextEngine.info.id).toBe("legacy");
|
|
expect(factoryCalls).toBe(1);
|
|
expect(assemble).toHaveBeenCalledTimes(1);
|
|
expect(listContextEngineQuarantines()).toEqual([
|
|
expect.objectContaining({
|
|
engineId,
|
|
owner: `test:${engineId}`,
|
|
operation: "assemble",
|
|
reason: "lcm db is corrupt",
|
|
}),
|
|
]);
|
|
expect(console.error).toHaveBeenCalledWith(
|
|
`[context-engine] Context engine "${engineId}" owner=test:${engineId} failed during assemble: lcm db is corrupt; quarantining it for this process and falling back to default engine "legacy".`,
|
|
);
|
|
});
|
|
|
|
it("exposes fallback metadata on the same engine after lifecycle quarantine", async () => {
|
|
const engineId = uniqueEngineId("runtime-fail-metadata");
|
|
const assemble = vi.fn(async () => {
|
|
throw new Error("plugin store unavailable");
|
|
});
|
|
registerContextEngineForOwner(
|
|
engineId,
|
|
() => ({
|
|
info: {
|
|
id: "lcm",
|
|
name: "Lossless Context Manager",
|
|
ownsCompaction: true,
|
|
},
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
assemble,
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
}),
|
|
"plugin:lossless-claw",
|
|
{ allowSameOwnerRefresh: true },
|
|
);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
expect(engine.info.ownsCompaction).toBe(true);
|
|
expect(resolveContextEngineOwnerPluginId(engine)).toBe("lossless-claw");
|
|
|
|
const message = makeMockMessage("user", "hello");
|
|
const result = await engine.assemble({
|
|
sessionId: "s1",
|
|
messages: [message],
|
|
});
|
|
|
|
expect(result.messages).toEqual([message]);
|
|
expect(engine.info.id).toBe("legacy");
|
|
expect(engine.info.ownsCompaction).toBeUndefined();
|
|
expect(resolveContextEngineOwnerPluginId(engine)).toBeUndefined();
|
|
expect(assemble).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("quarantines compact failures without same-call legacy fallback", async () => {
|
|
const engineId = uniqueEngineId("runtime-fail-compact");
|
|
const compact = vi.fn(async () => {
|
|
throw new Error("plugin compaction failed");
|
|
});
|
|
registerContextEngineForOwner(
|
|
engineId,
|
|
() => ({
|
|
info: {
|
|
id: "lcm",
|
|
name: "Lossless Context Manager",
|
|
ownsCompaction: true,
|
|
},
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
compact,
|
|
}),
|
|
"plugin:lossless-claw",
|
|
{ allowSameOwnerRefresh: true },
|
|
);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
await expect(
|
|
engine.compact({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:s1",
|
|
}),
|
|
).rejects.toThrow("plugin compaction failed");
|
|
|
|
expect(engine.info.id).toBe("legacy");
|
|
expect(engine.info.ownsCompaction).toBeUndefined();
|
|
expect(resolveContextEngineOwnerPluginId(engine)).toBeUndefined();
|
|
expect(compact).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("clears a missing-engine quarantine when the plugin registers later", async () => {
|
|
const engineId = uniqueEngineId("late-register");
|
|
const missingEngine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
expect(missingEngine.info.id).toBe("legacy");
|
|
expect(listContextEngineQuarantines()).toEqual([
|
|
expect.objectContaining({
|
|
engineId,
|
|
operation: "resolve",
|
|
reason: "not registered",
|
|
}),
|
|
]);
|
|
|
|
registerTestContextEngine(engineId, () => ({
|
|
info: { id: engineId, name: "Late Registered Engine" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
}));
|
|
|
|
const registeredEngine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
expect(listContextEngineQuarantines()).toEqual([]);
|
|
expect(registeredEngine.info.id).toBe(engineId);
|
|
});
|
|
|
|
it("does not quarantine abort rejections from lifecycle methods", async () => {
|
|
const engineId = uniqueEngineId("abort-rejection");
|
|
const abortError = new Error("compaction aborted");
|
|
abortError.name = "AbortError";
|
|
const controller = new AbortController();
|
|
registerTestContextEngine(engineId, () => ({
|
|
info: { id: engineId, name: "Abort Aware Engine" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
controller.abort(new Error("user stopped run"));
|
|
throw abortError;
|
|
},
|
|
}));
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
await expect(
|
|
engine.compact({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:s1",
|
|
abortSignal: controller.signal,
|
|
}),
|
|
).rejects.toThrow("compaction aborted");
|
|
|
|
const nextEngine = await resolveContextEngine(configWithSlot(engineId));
|
|
expect(nextEngine.info.id).toBe(engineId);
|
|
expect(listContextEngineQuarantines()).toEqual([]);
|
|
expect(console.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("quarantines subagent preparation failures while failing the active spawn closed", async () => {
|
|
const engineId = uniqueEngineId("prepare-subagent-fail");
|
|
registerTestContextEngine(engineId, () => ({
|
|
info: { id: engineId, name: "Spawn Aware Engine" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
async prepareSubagentSpawn() {
|
|
throw new Error("child context projection failed");
|
|
},
|
|
}));
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
|
|
await expect(
|
|
engine.prepareSubagentSpawn?.({
|
|
parentSessionKey: "agent:main",
|
|
childSessionKey: "agent:child",
|
|
contextMode: "isolated",
|
|
}),
|
|
).rejects.toThrow("child context projection failed");
|
|
|
|
const nextEngine = await resolveContextEngine(configWithSlot(engineId));
|
|
expect(nextEngine.info.id).toBe("legacy");
|
|
expect(listContextEngineQuarantines()).toEqual([
|
|
expect.objectContaining({
|
|
engineId,
|
|
operation: "prepareSubagentSpawn",
|
|
reason: "child context projection failed",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("throws when the default engine itself is not registered", async () => {
|
|
// Access the process-global registry via the well-known symbol and clear it
|
|
// so even the default engine is missing. The symbol key must match the
|
|
// private CONTEXT_ENGINE_REGISTRY_STATE constant in registry.ts — guard
|
|
// against a silent key mismatch so a rename surfaces loudly.
|
|
const registryState = requireRegistryState();
|
|
const snapshot = new Map(registryState.engines);
|
|
registryState.engines.clear();
|
|
|
|
try {
|
|
await expect(resolveContextEngine()).rejects.toThrow("not registered");
|
|
} finally {
|
|
for (const [key, value] of snapshot) {
|
|
registryState.engines.set(key, value);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("propagates error when default engine factory throws", async () => {
|
|
// Override the default "legacy" engine with a throwing factory via the
|
|
// core-owner path so the registration is accepted.
|
|
registerContextEngineForOwner(
|
|
"legacy",
|
|
() => {
|
|
throw new Error("default engine init failed");
|
|
},
|
|
"core",
|
|
{ allowSameOwnerRefresh: true },
|
|
);
|
|
|
|
await expect(resolveContextEngine()).rejects.toThrow("default engine init failed");
|
|
});
|
|
|
|
it("propagates error when default engine fails contract validation", async () => {
|
|
registerContextEngineForOwner(
|
|
"legacy",
|
|
() => ({ broken: true }) as unknown as ContextEngine,
|
|
"core",
|
|
{ allowSameOwnerRefresh: true },
|
|
);
|
|
|
|
await expect(resolveContextEngine()).rejects.toThrow(
|
|
'Context engine "legacy" factory returned an invalid ContextEngine',
|
|
);
|
|
});
|
|
|
|
it("accepts resolved engines whose info.id differs from the registered slot id (#66601)", async () => {
|
|
// Regression for openclaw/openclaw#66601: third-party plugins like
|
|
// lossless-claw register under an external slot id ("lossless-claw") but
|
|
// the ContextEngine they return uses the plugin's own internal id
|
|
// (e.g. "lcm"). That id is metadata, not the lookup key.
|
|
const engineId = `plugin-slot-${Date.now().toString(36)}`;
|
|
const internalInfoId = "lcm";
|
|
registerTestContextEngine(
|
|
engineId,
|
|
() =>
|
|
({
|
|
info: { id: internalInfoId, name: "Lossless Context Manager", version: "0.5.2" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
}) as unknown as ContextEngine,
|
|
);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
// The engine's own info.id is preserved; resolution does not overwrite it.
|
|
expect(engine.info.id).toBe(internalInfoId);
|
|
expect(engine.info.name).toBe("Lossless Context Manager");
|
|
// And the engine is usable through the wrapper.
|
|
const result = await engine.assemble({
|
|
sessionId: "s1",
|
|
messages: [makeMockMessage("user", "hello")],
|
|
});
|
|
expect(result.estimatedTokens).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 5. LegacyContextEngine parity
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("LegacyContextEngine parity", () => {
|
|
it("ingest() returns { ingested: false } (no-op)", async () => {
|
|
const engine = new LegacyContextEngine();
|
|
const result = await engine.ingest({
|
|
sessionId: "s1",
|
|
message: makeMockMessage(),
|
|
});
|
|
|
|
expect(result).toEqual({ ingested: false });
|
|
});
|
|
|
|
it("assemble() returns messages as-is (pass-through)", async () => {
|
|
const engine = new LegacyContextEngine();
|
|
const messages = [
|
|
makeMockMessage("user", "first"),
|
|
makeMockMessage("assistant", "second"),
|
|
makeMockMessage("user", "third"),
|
|
];
|
|
|
|
const result = await engine.assemble({
|
|
sessionId: "s1",
|
|
messages,
|
|
});
|
|
|
|
// Messages should be the exact same array reference (pass-through)
|
|
expect(result.messages).toBe(messages);
|
|
expect(result.messages).toHaveLength(3);
|
|
expect(result.estimatedTokens).toBe(0);
|
|
expect(result.systemPromptAddition).toBeUndefined();
|
|
});
|
|
|
|
it("dispose() completes without error", async () => {
|
|
const engine = new LegacyContextEngine();
|
|
await expect(engine.dispose()).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 5b. assemble() prompt forwarding
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("assemble() prompt forwarding", () => {
|
|
it("forwards prompt only when callers provide one", async () => {
|
|
const cases = [
|
|
{
|
|
name: "provided",
|
|
params: { prompt: "hello" },
|
|
expectedPrompt: "hello",
|
|
},
|
|
{
|
|
name: "omitted",
|
|
params: {},
|
|
expectedPrompt: null,
|
|
},
|
|
{
|
|
name: "conditional spread undefined",
|
|
params: (() => {
|
|
const callerPrompt: string | undefined = undefined;
|
|
return callerPrompt !== undefined ? { prompt: callerPrompt } : {};
|
|
})(),
|
|
expectedPrompt: null,
|
|
},
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
const engineId = uniqueEngineId(`prompt-${testCase.name.replace(/\s+/g, "-")}`);
|
|
const calls = registerPromptTrackingEngine(engineId);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
await engine.assemble({
|
|
sessionId: "s1",
|
|
messages: [makeMockMessage("user", "hello")],
|
|
...testCase.params,
|
|
});
|
|
|
|
expect(calls, testCase.name).toHaveLength(1);
|
|
if (testCase.expectedPrompt === null) {
|
|
expect(calls[0], testCase.name).not.toHaveProperty("prompt");
|
|
expect(Object.keys(calls[0] as object), testCase.name).not.toContain("prompt");
|
|
} else {
|
|
expect(calls[0], testCase.name).toHaveProperty("prompt", testCase.expectedPrompt);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("retries strict legacy assemble without sessionKey, prompt, and runtimeSettings", async () => {
|
|
const engineId = `prompt-legacy-${Date.now().toString(36)}`;
|
|
const strictEngine = new LegacyAssembleStrictEngine(engineId);
|
|
registerTestContextEngine(engineId, () => strictEngine);
|
|
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
const result = await engine.assemble({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
messages: [makeMockMessage("user", "hello")],
|
|
prompt: "hello",
|
|
runtimeSettings: { schemaVersion: 1 } as never,
|
|
});
|
|
|
|
expect(result.estimatedTokens).toBe(3);
|
|
expect(strictEngine.assembleCalls).toHaveLength(4);
|
|
expect(strictEngine.assembleCalls[0]).toHaveProperty("sessionKey", "agent:main:test");
|
|
expect(strictEngine.assembleCalls[0]).toHaveProperty("prompt", "hello");
|
|
expect(strictEngine.assembleCalls[0]).toHaveProperty("runtimeSettings");
|
|
expect(strictEngine.assembleCalls[1]).not.toHaveProperty("sessionKey");
|
|
expect(strictEngine.assembleCalls[1]).toHaveProperty("prompt", "hello");
|
|
expect(strictEngine.assembleCalls[1]).toHaveProperty("runtimeSettings");
|
|
expect(strictEngine.assembleCalls[2]).not.toHaveProperty("sessionKey");
|
|
expect(strictEngine.assembleCalls[2]).not.toHaveProperty("prompt");
|
|
expect(strictEngine.assembleCalls[2]).toHaveProperty("runtimeSettings");
|
|
expect(strictEngine.assembleCalls[3]).not.toHaveProperty("sessionKey");
|
|
expect(strictEngine.assembleCalls[3]).not.toHaveProperty("prompt");
|
|
expect(strictEngine.assembleCalls[3]).not.toHaveProperty("runtimeSettings");
|
|
});
|
|
|
|
it("retries strict legacy lifecycle hooks without additive host fields", async () => {
|
|
const runtimeSettings = { schemaVersion: 1 } as never;
|
|
const sessionTarget = {
|
|
agentId: "main",
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
} satisfies ContextEngineSessionTarget;
|
|
const runtimeContext = { transcriptStorage: { kind: "sqlite" } } as never;
|
|
const resolveStrictEngine = async () => {
|
|
const engineId = uniqueEngineId("runtime-settings-legacy");
|
|
const strictEngine = new LegacyRuntimeSettingsStrictEngine(engineId);
|
|
registerTestContextEngine(engineId, () => strictEngine);
|
|
const engine = await resolveContextEngine(configWithSlot(engineId));
|
|
return { engine, strictEngine };
|
|
};
|
|
|
|
const bootstrap = await resolveStrictEngine();
|
|
await bootstrap.engine.bootstrap?.({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
sessionFile: "sessions/s1.jsonl",
|
|
runtimeSettings,
|
|
sessionTarget,
|
|
runtimeContext,
|
|
});
|
|
|
|
const maintain = await resolveStrictEngine();
|
|
await maintain.engine.maintain?.({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
sessionFile: "sessions/s1.jsonl",
|
|
runtimeSettings,
|
|
sessionTarget,
|
|
runtimeContext,
|
|
});
|
|
|
|
const afterTurn = await resolveStrictEngine();
|
|
await afterTurn.engine.afterTurn?.({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
sessionFile: "sessions/s1.jsonl",
|
|
messages: [makeMockMessage("assistant", "done")],
|
|
prePromptMessageCount: 0,
|
|
runtimeSettings,
|
|
sessionTarget,
|
|
runtimeContext,
|
|
});
|
|
|
|
const assemble = await resolveStrictEngine();
|
|
await assemble.engine.assemble({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
messages: [makeMockMessage("user", "hello")],
|
|
runtimeSettings,
|
|
});
|
|
|
|
const compact = await resolveStrictEngine();
|
|
await compact.engine.compact({
|
|
sessionId: "s1",
|
|
sessionKey: "agent:main:test",
|
|
runtimeSettings,
|
|
sessionTarget,
|
|
runtimeContext,
|
|
});
|
|
|
|
for (const calls of [
|
|
bootstrap.strictEngine.bootstrapCalls,
|
|
maintain.strictEngine.maintainCalls,
|
|
afterTurn.strictEngine.afterTurnCalls,
|
|
assemble.strictEngine.assembleCalls,
|
|
compact.strictEngine.compactCalls,
|
|
]) {
|
|
const finalCall = calls.at(-1);
|
|
expect(finalCall).toBeDefined();
|
|
expect(calls[0]).toHaveProperty("runtimeSettings");
|
|
expect(calls[0]).toHaveProperty("sessionKey", "agent:main:test");
|
|
expect(finalCall).toHaveProperty("sessionKey", "agent:main:test");
|
|
expect(finalCall).not.toHaveProperty("runtimeSettings");
|
|
expect(finalCall).not.toHaveProperty("sessionTarget");
|
|
expect(finalCall).not.toHaveProperty("runtimeContext");
|
|
}
|
|
for (const calls of [
|
|
bootstrap.strictEngine.bootstrapCalls,
|
|
maintain.strictEngine.maintainCalls,
|
|
afterTurn.strictEngine.afterTurnCalls,
|
|
compact.strictEngine.compactCalls,
|
|
]) {
|
|
expect(calls).toHaveLength(4);
|
|
expect(calls[0]).toHaveProperty("sessionTarget", sessionTarget);
|
|
expect(calls[0]).toHaveProperty("runtimeContext", runtimeContext);
|
|
}
|
|
expect(assemble.strictEngine.assembleCalls).toHaveLength(2);
|
|
for (const call of assemble.strictEngine.assembleCalls) {
|
|
expect(call).not.toHaveProperty("sessionTarget");
|
|
expect(call).not.toHaveProperty("runtimeContext");
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 6. Initialization guard
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Initialization guard", () => {
|
|
it("ensureContextEnginesInitialized() is idempotent and registers legacy", async () => {
|
|
const { ensureContextEnginesInitialized } = await import("./init.js");
|
|
|
|
expect(ensureContextEnginesInitialized()).toBeUndefined();
|
|
expect(ensureContextEnginesInitialized()).toBeUndefined();
|
|
|
|
expect(getContextEngineRegistration("legacy")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 7. Bundle chunk isolation (#40096)
|
|
//
|
|
// Published builds may split the context-engine registry across multiple
|
|
// output chunks. The Symbol.for() keyed global ensures that a plugin
|
|
// registering an owned engine from chunk A is visible to
|
|
// resolveContextEngine() imported from chunk B.
|
|
//
|
|
// These tests exercise the invariant that failed in 2026.3.7 when
|
|
// lossless-claw registered successfully but resolution could not find it.
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("Bundle chunk isolation (#40096)", () => {
|
|
it("shares registrations and keeps concurrent chunk registration visible", async () => {
|
|
const ts = Date.now().toString(36);
|
|
const registryUrl = new URL("./registry.ts", import.meta.url).href;
|
|
const dynamicChunk = await import(/* @vite-ignore */ `${registryUrl}?chunk=${ts}-dynamic`);
|
|
const chunks = [
|
|
{
|
|
registerContextEngineForOwner,
|
|
getContextEngineRegistration,
|
|
resolveContextEngine,
|
|
},
|
|
dynamicChunk,
|
|
];
|
|
|
|
const engineId = `cross-chunk-${ts}`;
|
|
const factory = () => ({
|
|
info: { id: engineId, name: "Cross-chunk Engine", version: "0.0.1" },
|
|
async ingest() {
|
|
return { ingested: true };
|
|
},
|
|
async assemble({ messages }: { messages: AgentMessage[] }) {
|
|
return { messages, estimatedTokens: 0 };
|
|
},
|
|
async compact() {
|
|
return { ok: true, compacted: false };
|
|
},
|
|
});
|
|
chunks[0].registerContextEngineForOwner(engineId, factory, `test:${engineId}`);
|
|
|
|
expect(chunks[1].getContextEngineRegistration(engineId)?.factory).toBe(factory);
|
|
const engine = await chunks[1].resolveContextEngine(configWithSlot(engineId));
|
|
expect(engine.info.id).toBe(engineId);
|
|
|
|
const ids = chunks.map((_, i) => `concurrent-${ts}-${i}`);
|
|
const registrationTasks = chunks.map((chunk, i) =>
|
|
Promise.resolve().then(() => {
|
|
const id = `concurrent-${ts}-${i}`;
|
|
chunk.registerContextEngineForOwner(id, () => new MockContextEngine(), `test:${id}`);
|
|
}),
|
|
);
|
|
await Promise.all(registrationTasks);
|
|
|
|
for (const id of ids) {
|
|
expect(chunks[0].getContextEngineRegistration(id)).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|