// Covers context-engine message filtering, assemble validation, and turn finalization. import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { describe, expect, it, vi } from "vitest"; import { buildMemorySystemPromptAddition } from "../../context-engine/delegate.js"; import { CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, } from "../../context-engine/host-compat.js"; import { registerContextEngineForOwner, resolveContextEngine, } from "../../context-engine/registry.js"; import { buildContextEngineRuntimeSettings } from "../../context-engine/runtime-settings.js"; import type { ContextEngine, ContextEngineRuntimeContext, ContextEngineRuntimeSettings, } from "../../context-engine/types.js"; import { clearMemoryPluginState, registerMemoryPromptPreparation, registerTestMemoryPromptBuilder, type MemoryPromptSectionParams, } from "../../plugins/memory-state.test-fixtures.js"; import { compactContextEngineWithSafetyTimeout } from "../embedded-agent-runner/compaction-safety-timeout.js"; import { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE } from "../internal-runtime-context.js"; import { assembleHarnessContextEngine, bootstrapHarnessContextEngine, finalizeHarnessContextEngineTurn, } from "./context-engine-lifecycle.js"; function registerTestContextEngine( id: string, factory: Parameters[1], ) { return registerContextEngineForOwner(id, factory, `test:${id}`, { allowSameOwnerRefresh: true, }); } function textMessage(role: "user" | "assistant", text: string, timestamp: number): AgentMessage { return { role, content: [{ type: "text", text }], timestamp, } as AgentMessage; } function runtimeContextMessage(content: string, timestamp: number): AgentMessage { // Runtime context is hidden harness metadata. Context engines should see // user/assistant transcript messages, not this internal custom channel. return { role: "custom", customType: OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE, content, display: false, details: { source: "openclaw-runtime-context" }, timestamp, } as AgentMessage; } function createContextEngine(overrides: Partial = {}): ContextEngine { return { info: { id: "test", name: "Test context engine" }, ingest: vi.fn(async () => ({ ingested: true })), assemble: vi.fn(async (params) => ({ messages: params.messages, estimatedTokens: 0, })), compact: vi.fn(async () => ({ ok: true, compacted: false })), ...overrides, }; } const sessionParams = { sessionIdUsed: "session-1", sessionId: "session-1", sessionKey: "agent:main:main", sessionFile: "sessions/main.jsonl", }; let configuredProofEngineIdCounter = 0; function uniqueConfiguredProofEngineId() { configuredProofEngineIdCounter += 1; return `configured-runtime-settings-proof-${configuredProofEngineIdCounter}`; } describe("harness context engine lifecycle", () => { it("forwards session keys across bootstrap, assemble, and afterTurn hooks", async () => { const bootstrap = vi.fn(async () => ({ bootstrapped: true })); const assemble = vi.fn(async (params: Parameters[0]) => ({ messages: params.messages, estimatedTokens: 0, })); const afterTurn = vi.fn(async () => {}); const contextEngine = createContextEngine({ bootstrap, assemble, afterTurn }); await bootstrapHarnessContextEngine({ hadSessionFile: true, contextEngine, sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, sessionFile: sessionParams.sessionFile, runMaintenance: async () => undefined, warn: () => {}, }); await assembleHarnessContextEngine({ contextEngine, sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, messages: [textMessage("user", "ask", 1)], modelId: "gpt-test", }); await finalizeHarnessContextEngineTurn({ contextEngine, promptError: false, aborted: false, yieldAborted: false, sessionIdUsed: sessionParams.sessionIdUsed, sessionKey: sessionParams.sessionKey, sessionFile: sessionParams.sessionFile, messagesSnapshot: [textMessage("assistant", "done", 2)], prePromptMessageCount: 0, runMaintenance: async () => undefined, warn: () => {}, }); for (const hook of [bootstrap, assemble, afterTurn]) { expect(hook).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: sessionParams.sessionKey }), ); } }); it.each([false, true])( "isolates prepared memory tools across non-legacy turns with sandboxed=%s", async (sandboxed) => { const prepare = vi.fn(async (params: MemoryPromptSectionParams) => { const tools = [...params.availableTools].join(","); params.availableTools.add("preparer-only"); await Promise.resolve(); return ["## Prepared Memory", `sandboxed=${params.sandboxed}; tools=${tools}`, ""]; }); registerTestMemoryPromptBuilder(({ availableTools }) => { availableTools.add("builder-only"); return ["## Memory Recall", ""]; }); registerMemoryPromptPreparation("memory-wiki", prepare); const assemble = vi.fn(async (params: Parameters[0]) => ({ messages: params.messages, estimatedTokens: 0, systemPromptAddition: buildMemorySystemPromptAddition({ availableTools: params.availableTools ?? new Set(), citationsMode: params.citationsMode, }), })); try { for (const toolNames of [undefined, [], ["wiki_search"], ["memory_search"]]) { const availableTools = toolNames ? new Set(toolNames) : undefined; const result = await assembleHarnessContextEngine({ contextEngine: createContextEngine({ assemble }), sessionId: sessionParams.sessionId, sessionKey: "global", agentId: "support", messages: [textMessage("user", "visible ask", 1)], availableTools, citationsMode: "on", sandboxed, modelId: "gpt-test", }); expect(result?.systemPromptAddition).toBe( `## Memory Recall\n\n## Prepared Memory\nsandboxed=${sandboxed}; tools=${(toolNames ?? []).join(",")}`, ); expect([...(availableTools ?? [])]).toEqual(toolNames ?? []); expect(prepare).toHaveBeenLastCalledWith( expect.objectContaining({ agentId: "support", agentSessionKey: "global", sandboxed, }), ); } expect(buildMemorySystemPromptAddition({ availableTools: new Set() })).toBe( "## Memory Recall", ); } finally { clearMemoryPluginState(); } }, ); it("keeps hidden runtime-context custom messages out of assemble hooks", async () => { const visibleUser = textMessage("user", "visible ask", 1); const hiddenRuntimeContext = runtimeContextMessage("hidden runtime context", 2); const visibleAssistant = textMessage("assistant", "visible answer", 3); const assemble = vi.fn(async (params: Parameters[0]) => ({ messages: params.messages, estimatedTokens: 0, })); await assembleHarnessContextEngine({ contextEngine: createContextEngine({ assemble }), sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, messages: [visibleUser, hiddenRuntimeContext, visibleAssistant], modelId: "gpt-test", }); const assembleParams = assemble.mock.calls.at(0)?.[0]; expect(assembleParams?.messages).toEqual([visibleUser, visibleAssistant]); }); it("keeps persisted runtime-context carriers in place for append-only replay policies", async () => { // Prefix-bound thinking (Anthropic family) is signed over every earlier message, // including the carrier that followed its user turn; assembly must not drop it. const visibleUser = textMessage("user", "visible ask", 1); const persistedCarrier = runtimeContextMessage("persisted runtime context", 2); const visibleAssistant = textMessage("assistant", "visible answer", 3); const assemble = vi.fn(async (params: Parameters[0]) => ({ messages: params.messages, estimatedTokens: 0, })); await assembleHarnessContextEngine({ contextEngine: createContextEngine({ assemble }), sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, appendOnlyRuntimeContext: true, messages: [visibleUser, persistedCarrier, visibleAssistant], modelId: "claude-test", }); const assembleParams = assemble.mock.calls.at(0)?.[0]; expect(assembleParams?.messages).toEqual([visibleUser, persistedCarrier, visibleAssistant]); }); it("isolates the authoritative history array while preserving in-place assembly", async () => { const first = textMessage("user", "first", 1); const second = textMessage("assistant", "second", 2); const messages = [first, second]; const assemble = vi.fn(async (params: Parameters[0]) => { expect(params.messages).not.toBe(messages); expect(params.messages).toEqual(messages); expect(params.messages[0]).toBe(first); expect(params.messages[1]).toBe(second); params.messages.reverse(); return { messages: params.messages, estimatedTokens: 0 }; }); const assembled = await assembleHarnessContextEngine({ contextEngine: createContextEngine({ assemble }), sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, messages, modelId: "gpt-test", }); expect(messages).toEqual([first, second]); expect(assembled?.messages).toEqual([second, first]); expect(assembled?.messages[0]).toBe(second); expect(assembled?.messages[1]).toBe(first); }); it("preserves the authoritative history when assembly mutates then throws", async () => { const first = textMessage("user", "first", 1); const second = textMessage("assistant", "second", 2); const messages = [first, second]; const contextEngine = createContextEngine({ assemble: vi.fn(async ({ messages: workingMessages }) => { workingMessages.reverse(); workingMessages.pop(); throw new Error("assembly failed after windowing"); }), }); await expect( assembleHarnessContextEngine({ contextEngine, sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, messages, modelId: "gpt-test", }), ).rejects.toThrow("assembly failed after windowing"); expect(messages).toEqual([first, second]); expect(messages[0]).toBe(first); expect(messages[1]).toBe(second); }); it("passes declared runtime settings into assemble hooks", async () => { const visibleUser = textMessage("user", "visible ask", 1); const assemble = vi.fn(async (params: Parameters[0]) => ({ messages: params.messages, estimatedTokens: 0, })); await assembleHarnessContextEngine({ contextEngine: createContextEngine({ assemble }), sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, messages: [visibleUser], tokenBudget: 4096, modelId: "gpt-5.5", providerId: "openai", contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, }); const assembleParams = assemble.mock.calls.at(0)?.[0]; expect(assembleParams?.runtimeSettings).toMatchObject({ schemaVersion: 1, runtime: { host: "openclaw", mode: "normal", }, model: { resolved: "gpt-5.5", provider: "openai", }, contextEngineSelection: { selectedId: expect.any(String), source: "configured", }, executionHost: { id: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST.id, }, limits: { promptTokenBudget: 4096, }, }); }); it("passes runtime settings through a configured context engine across lifecycle hooks", async () => { const engineId = uniqueConfiguredProofEngineId(); const captured: Array<{ hook: "bootstrap" | "assemble" | "afterTurn" | "maintain" | "compact"; runtimeContext?: ContextEngineRuntimeContext; runtimeSettings?: ContextEngineRuntimeSettings; sessionTarget?: ContextEngineRuntimeContext["sessionTarget"]; }> = []; const sessionTarget = { agentId: "main", sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, storePath: "/tmp/state/openclaw.sqlite", }; const bootstrapRuntimeContext = { transcriptStorage: { kind: "sqlite" as const }, sessionTarget, promptCache: { observation: { broke: true, previousCacheRead: 5000, cacheRead: 2000, changes: [{ code: "systemPrompt", detail: "system prompt digest changed" }], }, }, } satisfies ContextEngineRuntimeContext; const engine = createContextEngine({ info: { id: engineId, name: "Configured runtime settings proof engine", acceptedHostParams: ["runtimeSettings", "runtimeContext", "sessionTarget"], }, bootstrap: vi.fn(async (params) => { captured.push({ hook: "bootstrap", runtimeContext: params.runtimeContext, runtimeSettings: params.runtimeSettings, sessionTarget: params.sessionTarget, }); return { bootstrapped: true }; }), assemble: vi.fn(async (params) => { captured.push({ hook: "assemble", runtimeSettings: params.runtimeSettings }); return { messages: params.messages, estimatedTokens: 0, }; }), afterTurn: vi.fn(async (params) => { captured.push({ hook: "afterTurn", runtimeContext: params.runtimeContext, runtimeSettings: params.runtimeSettings, sessionTarget: params.sessionTarget, }); }), maintain: vi.fn(async (params) => { captured.push({ hook: "maintain", runtimeContext: params.runtimeContext, runtimeSettings: params.runtimeSettings, sessionTarget: params.sessionTarget, }); return { changed: false, bytesFreed: 0, rewrittenEntries: 0 }; }), compact: vi.fn(async (params) => { captured.push({ hook: "compact", runtimeSettings: params.runtimeSettings }); return { ok: true, compacted: false }; }), }); registerTestContextEngine(engineId, () => engine); const configuredEngine = await resolveContextEngine({ plugins: { slots: { contextEngine: engineId } }, }); await bootstrapHarnessContextEngine({ hadSessionFile: true, contextEngine: configuredEngine, sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, sessionTarget, sessionFile: sessionParams.sessionFile, providerId: "openai", requestedModelId: "openai/gpt-5.5", modelId: "anthropic/claude-sonnet-4-6", fallbackReason: "primary_provider_5xx", runtimeContext: bootstrapRuntimeContext, warn: () => {}, }); await assembleHarnessContextEngine({ contextEngine: configuredEngine, sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, messages: [textMessage("user", "visible ask", 1)], tokenBudget: 2048, runtimeContext: bootstrapRuntimeContext, providerId: "openai", requestedModelId: "openai/gpt-5.5", modelId: "anthropic/claude-sonnet-4-6", fallbackReason: "primary_provider_5xx", }); await finalizeHarnessContextEngineTurn({ contextEngine: configuredEngine, promptError: false, aborted: false, yieldAborted: false, sessionIdUsed: sessionParams.sessionIdUsed, sessionKey: sessionParams.sessionKey, sessionTarget, sessionFile: sessionParams.sessionFile, messagesSnapshot: [ textMessage("user", "old ask", 1), textMessage("assistant", "old answer", 2), textMessage("user", "new ask", 3), textMessage("assistant", "new answer", 4), ], prePromptMessageCount: 2, tokenBudget: 2048, runtimeContext: bootstrapRuntimeContext, providerId: "openai", requestedModelId: "openai/gpt-5.5", modelId: "anthropic/claude-sonnet-4-6", fallbackReason: "primary_provider_5xx", warn: () => {}, }); const compactRuntimeSettings = buildContextEngineRuntimeSettings({ contextEngineHost: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST, provider: "openai", requestedModel: "openai/gpt-5.5", resolvedModel: "anthropic/claude-sonnet-4-6", selectedContextEngineId: engineId, contextEngineSelectionSource: "configured", promptTokenBudget: 2048, fallbackReason: "primary_provider_5xx", }); await compactContextEngineWithSafetyTimeout( configuredEngine, { sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, sessionTarget: { sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, }, tokenBudget: 2048, runtimeSettings: compactRuntimeSettings, }, 100, ); expect(new Set(captured.map((entry) => entry.hook))).toEqual( new Set(["bootstrap", "assemble", "afterTurn", "maintain", "compact"]), ); expect(captured.find((entry) => entry.hook === "bootstrap")?.runtimeContext).toEqual( bootstrapRuntimeContext, ); expect(captured.find((entry) => entry.hook === "bootstrap")?.sessionTarget).toEqual( sessionTarget, ); expect(captured.find((entry) => entry.hook === "afterTurn")?.sessionTarget).toEqual( sessionTarget, ); expect(captured.find((entry) => entry.hook === "afterTurn")?.runtimeContext).toEqual( bootstrapRuntimeContext, ); expect(captured.find((entry) => entry.hook === "maintain")?.sessionTarget).toEqual( sessionTarget, ); expect( captured.find((entry) => entry.hook === "maintain")?.runtimeContext?.sessionTarget, ).toEqual(sessionTarget); for (const entry of captured) { expect(entry.runtimeSettings).toMatchObject({ schemaVersion: 1, runtime: { mode: "fallback" }, model: { requested: "openai/gpt-5.5", resolved: "anthropic/claude-sonnet-4-6", provider: "openai", family: null, }, contextEngineSelection: { selectedId: engineId, source: "configured", }, diagnostics: { fallbackReason: "provider_unavailable", }, }); } }); it("never derives model.family from the model id (defaults to null)", async () => { const assemble = vi.fn(async (params: Parameters[0]) => ({ messages: params.messages, estimatedTokens: 0, })); await assembleHarnessContextEngine({ contextEngine: createContextEngine({ assemble }), sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, messages: [textMessage("user", "ask", 1)], modelId: "anthropic/claude-opus-4-8", providerId: "anthropic", }); const noFamily = assemble.mock.calls.at(0)?.[0]?.runtimeSettings?.model; // Regression: model.family must not mirror the model id. expect(noFamily?.resolved).toBe("anthropic/claude-opus-4-8"); expect(noFamily?.family).toBeNull(); await assembleHarnessContextEngine({ contextEngine: createContextEngine({ assemble }), sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, messages: [textMessage("user", "ask", 1)], modelId: "anthropic/claude-opus-4-8", providerId: "anthropic", modelFamily: "claude", }); const withFamily = assemble.mock.calls.at(1)?.[0]?.runtimeSettings?.model; // When a real family is supplied, it is carried through verbatim. expect(withFamily?.family).toBe("claude"); }); it("keeps hidden runtime-context custom messages out of afterTurn hooks", async () => { const beforePromptUser = textMessage("user", "old ask", 1); const beforePromptRuntimeContext = runtimeContextMessage("old hidden context", 2); const beforePromptAssistant = textMessage("assistant", "old answer", 3); const turnUser = textMessage("user", "new ask", 4); const turnRuntimeContext = runtimeContextMessage("new hidden context", 5); const turnAssistant = textMessage("assistant", "new answer", 6); const afterTurn = vi.fn(async () => {}); await finalizeHarnessContextEngineTurn({ contextEngine: createContextEngine({ afterTurn }), promptError: false, aborted: false, yieldAborted: false, sessionIdUsed: sessionParams.sessionIdUsed, sessionKey: sessionParams.sessionKey, sessionFile: sessionParams.sessionFile, messagesSnapshot: [ beforePromptUser, beforePromptRuntimeContext, beforePromptAssistant, turnUser, turnRuntimeContext, turnAssistant, ], prePromptMessageCount: 3, tokenBudget: 2048, runtimeContext: {}, runMaintenance: async () => undefined, warn: () => {}, }); const afterTurnCalls = (afterTurn as unknown as { mock: { calls: unknown[][] } }).mock.calls; const afterTurnParams = afterTurnCalls[0]?.[0] as | { messages?: AgentMessage[]; prePromptMessageCount?: number } | undefined; expect(afterTurnParams?.messages).toEqual([ beforePromptUser, beforePromptAssistant, turnUser, turnAssistant, ]); expect(afterTurnParams?.prePromptMessageCount).toBe(2); }); describe("assembleHarnessContextEngine result validation", () => { // Regression for #75541: plugins that return a malformed assemble result // previously poisoned activeSession.messages with `undefined`, which then // crashed downstream with "Cannot read properties of undefined (reading // 'length')". The harness wrapper now throws a descriptive error so the // runner's existing assemble try/catch can log the engine id and fall // back to the pipeline messages instead of corrupting session state. const visibleUser = textMessage("user", "ping", 1); async function runAssembleWithEngineResult(result: unknown) { return assembleHarnessContextEngine({ contextEngine: createContextEngine({ info: { id: "broken-engine", name: "Broken engine" }, assemble: vi.fn(async () => result as never), }), sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, messages: [visibleUser], modelId: "gpt-test", }); } it("passes through a well-formed AssembleResult unchanged", async () => { const wellFormed = { messages: [visibleUser], estimatedTokens: 0 }; await expect(runAssembleWithEngineResult(wellFormed)).resolves.toBe(wellFormed); }); it("rejects an undefined assemble result with the engine id", async () => { await expect(runAssembleWithEngineResult(undefined)).rejects.toThrow( /context engine "broken-engine"[\s\S]*messages/, ); }); it("rejects a null assemble result with the engine id", async () => { await expect(runAssembleWithEngineResult(null)).rejects.toThrow( /context engine "broken-engine"[\s\S]*messages/, ); }); it("rejects an assemble result missing the messages array (lossless-claw shape)", async () => { // Mirrors the malformed shape reported in #75541, where the plugin // returned an object that satisfied the truthy `if (!assembled)` guard // but omitted the required `messages` field. await expect(runAssembleWithEngineResult({ estimatedTokens: 0 })).rejects.toThrow( /assemble\(\) returned an invalid result[\s\S]*messages of type undefined/, ); }); it("rejects an assemble result whose messages field is not an array", async () => { await expect( runAssembleWithEngineResult({ messages: "all of them", estimatedTokens: 0 }), ).rejects.toThrow(/messages of type string/); }); it("rejects an assemble result whose messages field is null", async () => { await expect( runAssembleWithEngineResult({ messages: null, estimatedTokens: 0 }), ).rejects.toThrow(/messages of type null/); }); }); it("keeps hidden runtime-context custom messages out of ingestBatch fallbacks", async () => { const beforePromptUser = textMessage("user", "old ask", 1); const beforePromptRuntimeContext = runtimeContextMessage("old hidden context", 2); const beforePromptAssistant = textMessage("assistant", "old answer", 3); const turnUser = textMessage("user", "new ask", 4); const turnRuntimeContext = runtimeContextMessage("new hidden context", 5); const turnAssistant = textMessage("assistant", "new answer", 6); const ingestBatch = vi.fn(async () => ({ ingestedCount: 2 })); // The ingestBatch fallback receives only the current visible turn, with // hidden runtime context filtered and the pre-prompt history excluded. await finalizeHarnessContextEngineTurn({ contextEngine: createContextEngine({ ingestBatch }), promptError: false, aborted: false, yieldAborted: false, sessionIdUsed: sessionParams.sessionIdUsed, sessionKey: sessionParams.sessionKey, sessionFile: sessionParams.sessionFile, messagesSnapshot: [ beforePromptUser, beforePromptRuntimeContext, beforePromptAssistant, turnUser, turnRuntimeContext, turnAssistant, ], prePromptMessageCount: 3, tokenBudget: 2048, runtimeContext: {}, runMaintenance: async () => undefined, warn: () => {}, isHeartbeat: true, }); const ingestBatchCalls = (ingestBatch as unknown as { mock: { calls: unknown[][] } }).mock .calls; const ingestBatchParams = ingestBatchCalls[0]?.[0] as | { isHeartbeat?: boolean; messages?: AgentMessage[]; sessionKey?: string } | undefined; expect(ingestBatchParams?.messages).toEqual([turnUser, turnAssistant]); expect(ingestBatchParams?.isHeartbeat).toBe(true); expect(ingestBatchParams?.sessionKey).toBe(sessionParams.sessionKey); }); it("forwards heartbeat state to per-message ingest fallbacks", async () => { const turnUser = textMessage("user", "new ask", 4); const turnAssistant = textMessage("assistant", "new answer", 6); const ingest = vi.fn(async () => ({ ingested: true })); await finalizeHarnessContextEngineTurn({ contextEngine: createContextEngine({ ingest }), promptError: false, aborted: false, yieldAborted: false, sessionIdUsed: sessionParams.sessionIdUsed, sessionKey: sessionParams.sessionKey, sessionFile: sessionParams.sessionFile, messagesSnapshot: [turnUser, turnAssistant], prePromptMessageCount: 0, tokenBudget: 2048, runtimeContext: {}, runMaintenance: async () => undefined, warn: () => {}, isHeartbeat: true, }); const ingestCalls = (ingest as unknown as { mock: { calls: unknown[][] } }).mock.calls; expect(ingestCalls).toHaveLength(2); for (const call of ingestCalls) { const ingestParams = call[0] as { isHeartbeat?: boolean; sessionKey?: string }; expect(ingestParams.isHeartbeat).toBe(true); expect(ingestParams.sessionKey).toBe(sessionParams.sessionKey); } }); it.each(["afterTurn", "ingestBatch"] as const)( "skips turn maintenance when %s fails", async (failingHook) => { const runMaintenance = vi.fn(async () => undefined); const contextEngine = createContextEngine({ afterTurn: failingHook === "afterTurn" ? vi.fn(async () => { throw new Error("afterTurn failed"); }) : undefined, ingestBatch: failingHook === "ingestBatch" ? vi.fn(async () => { throw new Error("ingestBatch failed"); }) : undefined, }); await finalizeHarnessContextEngineTurn({ contextEngine, promptError: false, aborted: false, yieldAborted: false, sessionIdUsed: sessionParams.sessionIdUsed, sessionKey: sessionParams.sessionKey, sessionFile: sessionParams.sessionFile, messagesSnapshot: [textMessage("assistant", "done", 1)], prePromptMessageCount: 0, runMaintenance, warn: () => {}, }); expect(runMaintenance).not.toHaveBeenCalled(); }, ); it("runs bootstrap maintenance for existing sessions without bootstrap()", async () => { const runMaintenance = vi.fn(async () => undefined); await bootstrapHarnessContextEngine({ hadSessionFile: true, contextEngine: createContextEngine({ bootstrap: undefined, maintain: vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0, })), }), sessionId: sessionParams.sessionId, sessionKey: sessionParams.sessionKey, sessionFile: sessionParams.sessionFile, runMaintenance, warn: () => {}, }); expect(runMaintenance).toHaveBeenCalledWith( expect.objectContaining({ reason: "bootstrap", sessionKey: sessionParams.sessionKey, }), ); }); it.each([ { promptError: true, aborted: false, yieldAborted: false }, { promptError: false, aborted: true, yieldAborted: false }, { promptError: false, aborted: false, yieldAborted: true }, ])("does not advance context ingestion for unsuccessful turns: %o", async (terminal) => { const afterTurn = vi.fn(async () => {}); const ingest = vi.fn(async () => ({ ingested: true })); const ingestBatch = vi.fn(async () => ({ ingestedCount: 0 })); const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0, })); await finalizeHarnessContextEngineTurn({ contextEngine: createContextEngine({ afterTurn, ingest, ingestBatch, maintain }), ...terminal, sessionIdUsed: sessionParams.sessionIdUsed, sessionKey: sessionParams.sessionKey, sessionFile: sessionParams.sessionFile, messagesSnapshot: [textMessage("user", "failed", 1)], prePromptMessageCount: 0, warn: () => {}, }); expect(afterTurn).not.toHaveBeenCalled(); expect(ingest).not.toHaveBeenCalled(); expect(ingestBatch).not.toHaveBeenCalled(); expect(maintain).not.toHaveBeenCalled(); }); });