Files
openclaw/src/agents/cli-runner.context-engine.test.ts
T
Vito Cappello 5621979a46 fix(models): preserve session selection across fallback turns (#119325)
* feat(models): add session-only model selection

* fix(models): use trailing session scope option

* test(models): satisfy session scope lint

* fix(models): reject duplicate model options

* fix(models): clarify default and session scope

* fix(models): require complete session option tokens

* fix(models): report configured default dispatch

* fix(models): keep directive handler within lint limit

* fix(models): parse model options in either order

* fix(models): apply session scope to aliases

* fix(models): align alias scope with reply routing

* fix(discord): surface model selection scope in picker

* fix(models): preserve mixed-text model selection

* fix(models): centralize command selection ownership

* fix(models): align session scope lifecycle

* fix(models): preserve command and auth ownership

* fixup! fix(models): preserve command and auth ownership

* fix(auth): preserve scoped CLI provider discovery

* test(models): align result and cron fixtures

* test(models): nest result timing metadata

* fix(discord): narrow silent dispatch results

* fix(transcript): preserve admitted turn identity

* fix(context-engine): fence the admitted transcript turn

* fix(context-engine): stabilize plugin compatibility contract

* chore(plugin-sdk): refresh context engine API baseline

* chore(plugin-sdk): use Linux context engine API baseline

* fix(context-engine): align fallback ownership

* fix(fallback): scope auth skip cache by profile

* fix(context-engine): settle only accepted fallback turns

* refactor(sessions): issue canonical turn admissions

* refactor(context-engine): own logical turn advancement

* fix(context-engine): settle cron fallback winners

* fix(models): align picker and fallback transactions

* fix(delivery): notify block admission after queueing

* fix(sessions): preserve canonical admission receipts

* chore(plugin-sdk): refresh API baseline hash

* fix(context-engine): commit accepted turns durably

* fix(context-engine): validate durable host transitions

* fix(context-engine): preserve fallback turn ownership

* fix(context-engine): preserve queued turn order

* fix(models): preserve fallback retry ownership

* fix(context-engine): enforce durable transcript anchors

* fix(runtime): close fallback persistence gaps

* fix(context-engine): preflight fallback harnesses

* chore(plugin-sdk): use Linux API baseline

* fix(context-engine): drain durable commits before reads

* fix(models): scope harness auth failures by profile

* fix(codex): fence legacy transcript history

* fix(commands): honor suppressed directive interpretation

* chore(runtime): remove unused branch exports

* test(context-engine): derive private outbox payload type

* fix(context-engine): apply durable drain degradation

* fix(context-engine): recover durable turn intents

* fix(context-engine): settle durable turn intents

* refactor(context-engine): satisfy branch quality gates

* fix(context-engine): close durable recovery gaps

* fix(discord): preserve dropped model command outcome

* test(copilot): keep journal fixture types local

* fix(auto-reply): preserve model alias provenance

* fix: close model scope review gaps

* fix(models): close review-found scope leaks

* fix(review): satisfy branch line budgets

* fix(agents): preserve context engine turn facts

* fix(agents): finalize silent context turns

* fix(context-engine): preserve compatibility window

* test(agents): cover both harness preparations

* fix(context-engine): retain blocked turn advancements

* fix(models): parse compact runtime options

* fix(telegram): report runtime resets accurately

* fix(models): isolate automatic auth failure skips

* fix(context-engine): project commit turn host params

---------

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-08-07 16:19:12 +08:00

685 lines
26 KiB
TypeScript

/** Tests CLI runner integration with context-engine lifecycle hooks. */
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
import { afterEach, beforeAll, 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,
runBeforeAgentReplyForTurnMock,
prepareCliRunContextMock,
} = vi.hoisted(() => ({
executePreparedCliRunMock: vi.fn(),
loadCliSessionContextEngineMessagesMock: vi.fn(),
loadCliSessionHistoryMessagesMock: vi.fn(),
getGlobalHookRunnerMock: vi.fn(() => null),
runBeforeAgentReplyForTurnMock: vi.fn(async () => undefined),
prepareCliRunContextMock: vi.fn(),
}));
let runCliAgent: typeof import("./cli-runner.js").runCliAgent;
let runPreparedCliAgent: typeof import("./cli-runner.js").runPreparedCliAgent;
let restoreCliRunnerTestDeps: typeof import("./cli-runner.js").restoreCliRunnerTestDeps;
let setCliRunnerTestDeps: typeof import("./cli-runner.js").setCliRunnerTestDeps;
vi.mock("./cli-runner/execute.runtime.js", () => ({
executePreparedCliRun: executePreparedCliRunMock,
}));
vi.mock("./cli-runner/prepare.runtime.js", () => ({
prepareCliRunContext: prepareCliRunContextMock,
}));
vi.mock("./cli-runner/session-history.js", () => ({
loadCliSessionContextEngineMessages: loadCliSessionContextEngineMessagesMock,
loadCliSessionHistoryMessages: loadCliSessionHistoryMessagesMock,
}));
vi.mock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: getGlobalHookRunnerMock,
}));
vi.mock("../plugins/before-agent-reply.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../plugins/before-agent-reply.js")>()),
runBeforeAgentReplyForTurn: runBeforeAgentReplyForTurnMock,
}));
function textMessage(role: "user" | "assistant", text: string, timestamp: number): AgentMessage {
return {
role,
content: [{ type: "text", text }],
timestamp,
} as AgentMessage;
}
function createContextEngine(overrides: Partial<ContextEngine> = {}): ContextEngine {
// Minimal context engine keeps tests focused on runner lifecycle calls.
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 {
// Prepared contexts mirror the shape produced by prepare.runtime without
// loading full backend setup in every lifecycle assertion.
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: {
mode: "reuse",
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 {
// Context engines may use legacy string content or structured text blocks.
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", () => {
beforeAll(async () => {
({ restoreCliRunnerTestDeps, runCliAgent, runPreparedCliAgent, setCliRunnerTestDeps } =
await import("./cli-runner.js"));
});
beforeEach(() => {
executePreparedCliRunMock.mockReset();
executePreparedCliRunMock.mockResolvedValue({
text: " final answer ",
rawText: " final answer ",
sessionId: "external-cli-session-1",
usage: { input: 11, output: 7, total: 18 },
diagnosticUsage: { input: 21, output: 9, total: 30 },
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);
runBeforeAgentReplyForTurnMock.mockClear();
prepareCliRunContextMock.mockReset();
restoreCliRunnerTestDeps();
setCliRunnerTestDeps({
claudeCliSessionTranscriptHasContent: vi.fn(async () => true),
});
});
afterEach(() => {
restoreCliRunnerTestDeps();
});
it("keeps isolated completion outside hooks, history, and context-engine lifecycle", async () => {
const bootstrap = vi.fn<NonNullable<ContextEngine["bootstrap"]>>(async () => ({
bootstrapped: true,
}));
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
createMaintenanceResult(),
);
const dispose = vi.fn(async () => {});
const context = buildPreparedContext(
createContextEngine({ bootstrap, afterTurn, maintain, dispose }),
);
context.params.isolatedCompletion = true;
const result = await runPreparedCliAgent(context);
expect(result.payloads).toEqual([{ text: "final answer" }]);
expect(executePreparedCliRunMock).toHaveBeenCalledWith(context, undefined, undefined);
expect(getGlobalHookRunnerMock).not.toHaveBeenCalled();
expect(loadCliSessionHistoryMessagesMock).not.toHaveBeenCalled();
expect(loadCliSessionContextEngineMessagesMock).not.toHaveBeenCalled();
expect(bootstrap).not.toHaveBeenCalled();
expect(afterTurn).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
expect(dispose).not.toHaveBeenCalled();
});
it("skips the top-level before-reply hook for isolated completion", async () => {
const context = buildPreparedContext(createContextEngine());
context.params.isolatedCompletion = true;
prepareCliRunContextMock.mockResolvedValue(context);
await expect(runCliAgent(context.params)).resolves.toMatchObject({
payloads: [{ text: "final answer" }],
});
expect(prepareCliRunContextMock).toHaveBeenCalledOnce();
expect(runBeforeAgentReplyForTurnMock).not.toHaveBeenCalled();
});
it("finalizes successful CLI turns with the active context engine", async () => {
const bootstrap = vi.fn<NonNullable<ContextEngine["bootstrap"]>>(async () => ({
bootstrapped: true,
}));
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
createMaintenanceResult(),
);
const dispose = vi.fn(async () => {});
const contextEngine = createContextEngine({ bootstrap, afterTurn, maintain, dispose });
const context = buildPreparedContext(contextEngine);
context.params.bootstrapContextRunKind = "commitment-only";
const result = await runPreparedCliAgent(context);
expect(result.meta.agentMeta?.sessionId).toBe("external-cli-session-1");
expect(result.meta.agentMeta).toMatchObject({
usage: { input: 11, output: 7, total: 18 },
lastCallUsage: { input: 11, output: 7, total: 18 },
diagnosticUsage: { input: 21, output: 9, total: 30 },
});
expect(loadCliSessionContextEngineMessagesMock).toHaveBeenCalledWith({
sessionId: "openclaw-session-1",
sessionFile: "session.jsonl",
sessionKey: "agent:main:main",
agentId: "main",
config: undefined,
});
expect(loadCliSessionHistoryMessagesMock).not.toHaveBeenCalled();
expect(bootstrap).toHaveBeenCalledTimes(1);
const bootstrapParams = bootstrap.mock.calls[0]?.[0];
expect(bootstrapParams).toMatchObject({
sessionId: "openclaw-session-1",
sessionKey: "agent:main:main",
sessionFile: "session.jsonl",
runtimeSettings: {
schemaVersion: 1,
runtime: { host: "openclaw", mode: "normal" },
model: {
provider: "claude-cli",
requested: null,
resolved: "sonnet-4.6",
},
contextEngineSelection: {
selectedId: expect.any(String),
source: "configured",
},
executionHost: {
id: "cli:claude-cli",
label: 'CLI backend "claude-cli"',
},
},
});
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,
isHeartbeat: true,
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 emit CLI turn facts without transcript admission", async () => {
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
createMaintenanceResult(),
);
const dispose = vi.fn(async () => {});
const context = buildPreparedContext(createContextEngine({ afterTurn, maintain, dispose }));
const onContextEngineTurnCandidate = vi.fn();
context.params.onContextEngineTurnCandidate = onContextEngineTurnCandidate;
prepareCliRunContextMock.mockResolvedValue(context);
await runCliAgent(context.params);
expect(onContextEngineTurnCandidate).not.toHaveBeenCalled();
expect(afterTurn).not.toHaveBeenCalled();
expect(maintain).toHaveBeenCalledTimes(1);
expect(dispose).not.toHaveBeenCalled();
});
it("uses the admitted user anchor for an accepted transcriptless CLI turn", async () => {
const context = buildPreparedContext(createContextEngine());
executePreparedCliRunMock.mockResolvedValue({
text: "",
rawText: "",
didSendViaMessagingTool: true,
sessionId: "external-cli-session-1",
usage: { input: 11, output: 0, total: 11 },
diagnosticUsage: { input: 21, output: 0, total: 21 },
finalPromptText: "prompt sent to cli",
});
const admission = {
agentId: "main",
sessionId: "openclaw-session-1",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-cli-context-engine-test/sessions.json",
generation: "generation-1",
entryId: "cli-user",
rawSeq: 1,
effectiveParentId: null,
activeMessagePosition: 0,
logicalTurnId: "cli-turn",
role: "user" as const,
};
const onContextEngineTurnCandidate = vi.fn();
context.params.onContextEngineTurnCandidate = onContextEngineTurnCandidate;
context.params.userTurnTranscriptRecorder = {
message: undefined,
resolveMessage: vi.fn(async () => undefined),
getAdmissionReceipt: () => admission,
markRuntimePersistencePending: vi.fn(),
markRuntimePersisted: vi.fn(),
markBlocked: vi.fn(),
hasPersisted: () => true,
isBlocked: () => false,
hasRuntimePersistencePending: () => false,
waitForRuntimePersistence: vi.fn(async () => {}),
persistApproved: vi.fn(async () => undefined),
persistBlocked: vi.fn(async () => undefined),
persistFallback: vi.fn(async () => undefined),
};
await runPreparedCliAgent(context);
expect(onContextEngineTurnCandidate).toHaveBeenCalledWith(
expect.objectContaining({
boundary: { admission, terminal: admission },
}),
);
});
it("uses the admitted user anchor as the terminal for transcriptless room events", async () => {
const context = buildPreparedContext(createContextEngine());
const admission = {
agentId: "main",
sessionId: "openclaw-session-1",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-cli-context-engine-test/sessions.json",
generation: "generation-1",
entryId: "room-event-user",
rawSeq: 1,
effectiveParentId: null,
activeMessagePosition: 0,
logicalTurnId: "room-event-turn",
role: "user" as const,
};
const onContextEngineTurnCandidate = vi.fn();
context.params.currentInboundEventKind = "room_event";
context.params.persistAssistantTranscript = false;
context.params.onContextEngineTurnCandidate = onContextEngineTurnCandidate;
context.params.userTurnTranscriptRecorder = {
message: undefined,
resolveMessage: vi.fn(async () => undefined),
getAdmissionReceipt: () => admission,
markRuntimePersistencePending: vi.fn(),
markRuntimePersisted: vi.fn(),
markBlocked: vi.fn(),
hasPersisted: () => true,
isBlocked: () => false,
hasRuntimePersistencePending: () => false,
waitForRuntimePersistence: vi.fn(async () => {}),
persistApproved: vi.fn(async () => undefined),
persistBlocked: vi.fn(async () => undefined),
persistFallback: vi.fn(async () => undefined),
};
await runPreparedCliAgent(context);
expect(onContextEngineTurnCandidate).toHaveBeenCalledWith(
expect.objectContaining({
boundary: { admission, terminal: admission },
sessionIdUsed: "openclaw-session-1",
sessionKey: "agent:main:main",
}),
);
});
it("does not synthesize a context-engine user turn for empty transcript prompts", async () => {
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const dispose = vi.fn(async () => {});
const contextEngine = createContextEngine({ afterTurn, dispose });
const context = buildPreparedContext(contextEngine);
context.params.transcriptPrompt = "";
context.contextEngineTurnPrompt = "";
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<NonNullable<ContextEngine["afterTurn"]>>(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";
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<NonNullable<ContextEngine["afterTurn"]>>(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);
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<NonNullable<ContextEngine["bootstrap"]>>(async () => {
loadCliSessionContextEngineMessagesMock.mockResolvedValueOnce(postBootstrapHistory);
return { bootstrapped: true };
});
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const dispose = vi.fn(async () => {});
const contextEngine = createContextEngine({ bootstrap, afterTurn, dispose });
const context = buildPreparedContext(contextEngine);
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<NonNullable<ContextEngine["ingestBatch"]>>(async () => ({
ingestedCount: 2,
}));
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
createMaintenanceResult(),
);
const dispose = vi.fn(async () => {});
const contextEngine = createContextEngine({ ingestBatch, maintain, dispose });
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<NonNullable<ContextEngine["maintain"]>>(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 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,
});
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<NonNullable<ContextEngine["maintain"]>>(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,
});
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<NonNullable<ContextEngine["bootstrap"]>>(async () => ({
bootstrapped: true,
}));
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const ingestBatch = vi.fn<NonNullable<ContextEngine["ingestBatch"]>>(async () => ({
ingestedCount: 0,
}));
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
createMaintenanceResult(),
);
const dispose = vi.fn(async () => {});
const contextEngine = createContextEngine({
bootstrap,
afterTurn,
ingestBatch,
maintain,
dispose,
});
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<NonNullable<ContextEngine["bootstrap"]>>(async () => ({
bootstrapped: true,
}));
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const ingestBatch = vi.fn<NonNullable<ContextEngine["ingestBatch"]>>(async () => ({
ingestedCount: 0,
}));
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
createMaintenanceResult(),
);
const dispose = vi.fn(async () => {});
const contextEngine = createContextEngine({
bootstrap,
afterTurn,
ingestBatch,
maintain,
dispose,
});
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 });
await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toThrow(
"cli boom",
);
expect(dispose).not.toHaveBeenCalled();
});
});