import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ContextEngine } from "../context-engine/types.js"; import type { PreparedCliRunContext } from "./cli-runner/types.js"; const { executePreparedCliRunMock, loadCliSessionContextEngineMessagesMock, loadCliSessionHistoryMessagesMock, getGlobalHookRunnerMock, } = vi.hoisted(() => ({ executePreparedCliRunMock: vi.fn(), loadCliSessionContextEngineMessagesMock: vi.fn(), loadCliSessionHistoryMessagesMock: vi.fn(), getGlobalHookRunnerMock: vi.fn(() => null), })); vi.mock("./cli-runner/execute.runtime.js", () => ({ executePreparedCliRun: executePreparedCliRunMock, })); vi.mock("./cli-runner/session-history.js", () => ({ loadCliSessionContextEngineMessages: loadCliSessionContextEngineMessagesMock, loadCliSessionHistoryMessages: loadCliSessionHistoryMessagesMock, })); vi.mock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: getGlobalHookRunnerMock, })); function textMessage(role: "user" | "assistant", text: string, timestamp: number): AgentMessage { return { role, content: [{ type: "text", text }], timestamp, } as AgentMessage; } function createContextEngine(overrides: Partial = {}): ContextEngine { return { info: { id: "test-context-engine", 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, }; } function createMaintenanceResult() { return { changed: false, bytesFreed: 0, rewrittenEntries: 0, }; } function buildPreparedContext(contextEngine: ContextEngine): PreparedCliRunContext { const backend = { command: "claude", args: ["--print"], output: "text" as const, input: "arg" as const, sessionMode: "existing" as const, serialize: true, }; return { params: { sessionId: "openclaw-session-1", sessionKey: "agent:main:main", agentId: "main", sessionFile: "session.jsonl", workspaceDir: "/tmp/openclaw-cli-context-engine-test", prompt: "visible ask", transcriptPrompt: "transcript visible ask", provider: "claude-cli", model: "sonnet-4.6", thinkLevel: "low", timeoutMs: 1_000, runId: "run-1", }, started: Date.now(), workspaceDir: "/tmp/openclaw-cli-context-engine-test", backendResolved: { id: "claude-cli", config: backend, bundleMcp: false, pluginId: "anthropic", }, preparedBackend: { backend, env: {}, }, reusableCliSession: { sessionId: "existing-external-cli-session", }, hadSessionFile: true, contextEngineConfig: {}, contextEngine, contextEngineTurnPrompt: "transcript visible ask", modelId: "sonnet-4.6", normalizedModel: "sonnet-4.6", systemPrompt: "You are a helpful assistant.", systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], bootstrapPromptWarningLines: [], authEpochVersion: 2, }; } function expectMessageText(message: AgentMessage | undefined, expected: string): void { expect(message).toBeDefined(); const content = (message as { content?: unknown } | undefined)?.content; if (typeof content === "string") { expect(content).toBe(expected); return; } expect(Array.isArray(content)).toBe(true); expect((content as unknown[] | undefined)?.[0]).toMatchObject({ type: "text", text: expected }); } describe("runPreparedCliAgent context engine lifecycle", () => { beforeEach(() => { executePreparedCliRunMock.mockReset(); executePreparedCliRunMock.mockResolvedValue({ text: " final answer ", rawText: " final answer ", sessionId: "external-cli-session-1", usage: { input: 11, output: 7, total: 18 }, finalPromptText: "prompt sent to cli", }); loadCliSessionContextEngineMessagesMock.mockReset(); loadCliSessionContextEngineMessagesMock.mockResolvedValue([ textMessage("user", "old ask", 1), textMessage("assistant", "old answer", 2), ]); loadCliSessionHistoryMessagesMock.mockReset(); loadCliSessionHistoryMessagesMock.mockResolvedValue([]); getGlobalHookRunnerMock.mockReset(); getGlobalHookRunnerMock.mockReturnValue(null); }); it("finalizes successful CLI turns with the active context engine", async () => { const bootstrap = vi.fn>(async () => ({ bootstrapped: true, })); const afterTurn = vi.fn>(async () => {}); const maintain = vi.fn>(async () => createMaintenanceResult(), ); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ bootstrap, afterTurn, maintain, dispose }); const context = buildPreparedContext(contextEngine); const { runPreparedCliAgent } = await import("./cli-runner.js"); const result = await runPreparedCliAgent(context); expect(result.meta.agentMeta?.sessionId).toBe("external-cli-session-1"); expect(loadCliSessionContextEngineMessagesMock).toHaveBeenCalledWith({ sessionId: "openclaw-session-1", sessionFile: "session.jsonl", sessionKey: "agent:main:main", agentId: "main", config: undefined, }); expect(loadCliSessionHistoryMessagesMock).not.toHaveBeenCalled(); expect(bootstrap).toHaveBeenCalledWith({ sessionId: "openclaw-session-1", sessionKey: "agent:main:main", sessionFile: "session.jsonl", }); expect(afterTurn).toHaveBeenCalledTimes(1); const afterTurnParams = afterTurn.mock.calls[0]?.[0]; expect(afterTurnParams).toMatchObject({ sessionId: "openclaw-session-1", sessionKey: "agent:main:main", sessionFile: "session.jsonl", prePromptMessageCount: 2, tokenBudget: undefined, runtimeContext: undefined, }); expect(afterTurnParams?.messages).toHaveLength(4); expect(afterTurnParams?.messages.slice(0, 2)).toEqual([ textMessage("user", "old ask", 1), textMessage("assistant", "old answer", 2), ]); expectMessageText(afterTurnParams?.messages[2], "transcript visible ask"); expectMessageText(afterTurnParams?.messages[3], "final answer"); expect(afterTurnParams?.messages[3]).toMatchObject({ role: "assistant", provider: "claude-cli", model: "sonnet-4.6", usage: { input: 11, output: 7, total: 18 }, }); expect(maintain).toHaveBeenCalledTimes(2); expect(maintain.mock.calls[1]?.[0]).toMatchObject({ sessionId: "openclaw-session-1", sessionKey: "agent:main:main", sessionFile: "session.jsonl", runtimeContext: { rewriteTranscriptEntries: expect.any(Function), llm: { complete: expect.any(Function) }, }, }); expect(dispose).not.toHaveBeenCalled(); }); it("does not synthesize a context-engine user turn for empty transcript prompts", async () => { const afterTurn = vi.fn>(async () => {}); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ afterTurn, dispose }); const context = buildPreparedContext(contextEngine); context.params.transcriptPrompt = ""; context.contextEngineTurnPrompt = ""; const { runPreparedCliAgent } = await import("./cli-runner.js"); await runPreparedCliAgent(context); const afterTurnParams = afterTurn.mock.calls[0]?.[0]; expect(afterTurnParams?.messages).toHaveLength(3); expect(afterTurnParams?.prePromptMessageCount).toBe(2); expect(afterTurnParams?.messages.slice(0, 2)).toEqual([ textMessage("user", "old ask", 1), textMessage("assistant", "old answer", 2), ]); const turnMessages = afterTurnParams?.messages.slice(afterTurnParams.prePromptMessageCount); expect(turnMessages).toHaveLength(1); expectMessageText(turnMessages?.[0], "final answer"); expect(dispose).not.toHaveBeenCalled(); }); it("does not finalize prepared model prompt as transcript turn text", async () => { const afterTurn = vi.fn>(async () => {}); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ afterTurn, dispose }); const context = buildPreparedContext(contextEngine); context.params.prompt = "runtime context\n\noriginal user ask"; delete context.params.transcriptPrompt; context.contextEngineTurnPrompt = "original user ask"; const { runPreparedCliAgent } = await import("./cli-runner.js"); await runPreparedCliAgent(context); const afterTurnParams = afterTurn.mock.calls[0]?.[0]; expect(afterTurnParams?.messages).toHaveLength(4); expect(afterTurnParams?.prePromptMessageCount).toBe(2); const turnMessages = afterTurnParams?.messages.slice(afterTurnParams.prePromptMessageCount); expect(turnMessages).toHaveLength(2); expectMessageText(turnMessages?.[0], "original user ask"); expectMessageText(turnMessages?.[1], "final answer"); expect(dispose).not.toHaveBeenCalled(); }); it("loads unbounded context-engine history separately from hook history", async () => { const afterTurn = vi.fn>(async () => {}); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ afterTurn, dispose }); const context = buildPreparedContext(contextEngine); const fullHistory = Array.from({ length: 101 }, (_, index) => textMessage("user", `old ask ${index}`, index), ); loadCliSessionContextEngineMessagesMock.mockResolvedValueOnce(fullHistory); const { runPreparedCliAgent } = await import("./cli-runner.js"); await runPreparedCliAgent(context); const afterTurnParams = afterTurn.mock.calls[0]?.[0]; expect(loadCliSessionContextEngineMessagesMock).toHaveBeenCalledTimes(1); expect(loadCliSessionHistoryMessagesMock).not.toHaveBeenCalled(); expect(afterTurnParams?.prePromptMessageCount).toBe(101); expect(afterTurnParams?.messages.slice(0, 101)).toEqual(fullHistory); }); it("loads context-engine history after bootstrap lifecycle runs", async () => { const postBootstrapHistory = [textMessage("user", "post-bootstrap history", 9)]; const bootstrap = vi.fn>(async () => { loadCliSessionContextEngineMessagesMock.mockResolvedValueOnce(postBootstrapHistory); return { bootstrapped: true }; }); const afterTurn = vi.fn>(async () => {}); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ bootstrap, afterTurn, dispose }); const context = buildPreparedContext(contextEngine); const { runPreparedCliAgent } = await import("./cli-runner.js"); await runPreparedCliAgent(context); expect(bootstrap).toHaveBeenCalledTimes(1); expect(loadCliSessionContextEngineMessagesMock).toHaveBeenCalledTimes(1); const bootstrapOrder = bootstrap.mock.invocationCallOrder[0]; const loadHistoryOrder = loadCliSessionContextEngineMessagesMock.mock.invocationCallOrder[0]; if (typeof bootstrapOrder !== "number" || typeof loadHistoryOrder !== "number") { throw new Error("Expected bootstrap and history load invocation order"); } expect(bootstrapOrder).toBeLessThan(loadHistoryOrder); const afterTurnParams = afterTurn.mock.calls[0]?.[0]; expect(afterTurnParams?.prePromptMessageCount).toBe(1); expect(afterTurnParams?.messages[0]).toEqual(postBootstrapHistory[0]); }); it("falls back to ingestBatch and still runs turn maintenance", async () => { const ingestBatch = vi.fn>(async () => ({ ingestedCount: 2, })); const maintain = vi.fn>(async () => createMaintenanceResult(), ); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ ingestBatch, maintain, dispose }); const { runPreparedCliAgent } = await import("./cli-runner.js"); await runPreparedCliAgent(buildPreparedContext(contextEngine)); expect(ingestBatch).toHaveBeenCalledTimes(1); const ingestBatchParams = ingestBatch.mock.calls[0]?.[0]; expect(ingestBatchParams).toMatchObject({ sessionId: "openclaw-session-1", sessionKey: "agent:main:main", }); expect(ingestBatchParams?.messages).toHaveLength(2); expectMessageText(ingestBatchParams?.messages[0], "transcript visible ask"); expectMessageText(ingestBatchParams?.messages[1], "final answer"); expect(maintain).toHaveBeenCalledTimes(2); expect(dispose).not.toHaveBeenCalled(); }); it("preserves deferred maintenance ownership for background engines", async () => { const maintain = vi.fn>(async () => createMaintenanceResult(), ); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ info: { id: "test-background-context-engine", name: "Test background context engine", turnMaintenanceMode: "background", }, maintain, dispose, }); const { runPreparedCliAgent } = await import("./cli-runner.js"); const context = buildPreparedContext(contextEngine); await runPreparedCliAgent(context); expect(dispose).not.toHaveBeenCalled(); expect(context.contextEngineDeferredTurnMaintenance).toBeDefined(); await context.contextEngineDeferredTurnMaintenance; expect(dispose).not.toHaveBeenCalled(); }); it("does not dispose background engines when no deferred turn maintenance is queued", async () => { const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ info: { id: "test-background-context-engine", name: "Test background context engine", turnMaintenanceMode: "background", }, dispose, }); const { runPreparedCliAgent } = await import("./cli-runner.js"); await runPreparedCliAgent(buildPreparedContext(contextEngine)); expect(dispose).not.toHaveBeenCalled(); }); it("does not dispose background engines after failed CLI attempts", async () => { executePreparedCliRunMock.mockRejectedValue(new Error("cli boom")); const maintain = vi.fn>(async () => createMaintenanceResult(), ); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ info: { id: "test-background-context-engine", name: "Test background context engine", turnMaintenanceMode: "background", }, maintain, dispose, }); const { runPreparedCliAgent } = await import("./cli-runner.js"); await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toThrow( "cli boom", ); expect(maintain).toHaveBeenCalledTimes(1); expect(dispose).not.toHaveBeenCalled(); }); it("does not finalize or run turn maintenance on failed CLI attempts", async () => { executePreparedCliRunMock.mockRejectedValue(new Error("cli boom")); const bootstrap = vi.fn>(async () => ({ bootstrapped: true, })); const afterTurn = vi.fn>(async () => {}); const ingestBatch = vi.fn>(async () => ({ ingestedCount: 0, })); const maintain = vi.fn>(async () => createMaintenanceResult(), ); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ bootstrap, afterTurn, ingestBatch, maintain, dispose, }); const { runPreparedCliAgent } = await import("./cli-runner.js"); await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toThrow( "cli boom", ); expect(bootstrap).toHaveBeenCalledTimes(1); expect(afterTurn).not.toHaveBeenCalled(); expect(ingestBatch).not.toHaveBeenCalled(); expect(maintain).toHaveBeenCalledTimes(1); expect(dispose).not.toHaveBeenCalled(); }); it("does not finalize context-engine turns for empty successful CLI output", async () => { executePreparedCliRunMock.mockResolvedValue({ text: " ", rawText: " ", sessionId: "external-cli-session-empty", usage: { input: 11, output: 0, total: 11 }, }); const bootstrap = vi.fn>(async () => ({ bootstrapped: true, })); const afterTurn = vi.fn>(async () => {}); const ingestBatch = vi.fn>(async () => ({ ingestedCount: 0, })); const maintain = vi.fn>(async () => createMaintenanceResult(), ); const dispose = vi.fn(async () => {}); const contextEngine = createContextEngine({ bootstrap, afterTurn, ingestBatch, maintain, dispose, }); const { runPreparedCliAgent } = await import("./cli-runner.js"); await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toMatchObject({ name: "FailoverError", reason: "empty_response", provider: "claude-cli", model: "sonnet-4.6", sessionId: "openclaw-session-1", }); expect(bootstrap).toHaveBeenCalledTimes(1); expect(afterTurn).not.toHaveBeenCalled(); expect(ingestBatch).not.toHaveBeenCalled(); expect(maintain).toHaveBeenCalledTimes(1); expect(dispose).not.toHaveBeenCalled(); }); it("does not dispose context engines when CLI attempts fail", async () => { executePreparedCliRunMock.mockRejectedValue(new Error("cli boom")); const dispose = vi.fn(async () => { throw new Error("dispose boom"); }); const contextEngine = createContextEngine({ dispose }); const { runPreparedCliAgent } = await import("./cli-runner.js"); await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toThrow( "cli boom", ); expect(dispose).not.toHaveBeenCalled(); }); });