| |
| import fs from "node:fs/promises"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { expectDefined } from "@openclaw/normalization-core"; |
| import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { createDeferred } from "../../../test/helpers/promise.js"; |
| import { replaceSessionEntry } from "../../config/sessions/session-accessor.js"; |
| import { SESSION_TOTAL_TOKENS_VERSION, type SessionEntry } from "../../config/sessions/types.js"; |
| import type { OpenClawConfig } from "../../config/types.openclaw.js"; |
| import { |
| registerContextEngineInRegistry, |
| resolveContextEngine as resolveContextEngineFromRegistry, |
| } from "../../context-engine/registry.js"; |
| import type { ContextEngine } from "../../context-engine/types.js"; |
| import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js"; |
| import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; |
| import { PluginRegistryInspectionResources } from "../../plugins/registry-inspection-resources.js"; |
| import { retireInspectionInstances } from "../../plugins/registry-inspection.test-support.js"; |
| import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; |
| import { getAsyncWorkSignal } from "../../shared/async-work-scope.js"; |
| import { withEnv } from "../../test-utils/env.js"; |
| import { resolveCliBackendConfig } from "../cli-backends.js"; |
| import { createModelGenerationFixture } from "../embedded-agent-runner/model.generation-scope.test-support.js"; |
| import { SessionManager } from "../sessions/session-manager.js"; |
| import { cliCompactionBackendEntrypoints } from "./cli-compaction-runtime.test-support.js"; |
| import { |
| resetCliCompactionTestDeps, |
| runCliTurnCompactionLifecycle, |
| setCliCompactionTestDeps, |
| } from "./cli-compaction.js"; |
| import { recordCliCompactionInStore as recordCliCompactionInStoreImpl } from "./session-store.js"; |
|
|
| function buildContextEngine(params: { |
| compactCalls: Array<Parameters<ContextEngine["compact"]>[0]>; |
| }): ContextEngine { |
| return { |
| info: { |
| id: "legacy", |
| name: "Legacy Context Engine", |
| }, |
| async ingest() { |
| return { ingested: false }; |
| }, |
| async assemble(assembleParams) { |
| return { messages: assembleParams.messages, estimatedTokens: 0 }; |
| }, |
| async compact(compactParams) { |
| params.compactCalls.push(compactParams); |
| return { |
| ok: true, |
| compacted: true, |
| result: { |
| summary: "compacted", |
| tokensBefore: compactParams.currentTokenCount ?? 0, |
| tokensAfter: 100, |
| }, |
| }; |
| }, |
| }; |
| } |
|
|
| async function writeSessionFile(params: { sessionFile: string; sessionId: string }) { |
| |
| |
| await fs.mkdir(path.dirname(params.sessionFile), { recursive: true }); |
| await fs.writeFile( |
| params.sessionFile, |
| [ |
| JSON.stringify({ |
| type: "session", |
| version: CURRENT_SESSION_VERSION, |
| id: params.sessionId, |
| timestamp: new Date(0).toISOString(), |
| cwd: path.dirname(params.sessionFile), |
| }), |
| JSON.stringify({ |
| type: "message", |
| message: { role: "user", content: "old ask", timestamp: 1 }, |
| }), |
| JSON.stringify({ |
| type: "message", |
| message: { |
| role: "assistant", |
| content: [{ type: "text", text: "old answer" }], |
| timestamp: 2, |
| }, |
| }), |
| "", |
| ].join("\n"), |
| "utf-8", |
| ); |
| } |
|
|
| async function persistSessionEntry(params: { |
| sessionKey: string; |
| storePath: string; |
| entry: SessionEntry; |
| }) { |
| await replaceSessionEntry( |
| { |
| sessionKey: params.sessionKey, |
| storePath: params.storePath, |
| }, |
| params.entry, |
| ); |
| } |
|
|
| type CliCompactionTestDeps = Parameters<typeof setCliCompactionTestDeps>[0]; |
| type CliCompactionParams = Parameters<typeof runCliTurnCompactionLifecycle>[0]; |
| type CompactParams = Parameters<ContextEngine["compact"]>[0]; |
|
|
| const defaultSettingsManager = async () => ({ |
| getCompactionReserveTokens: () => 200, |
| getCompactionKeepRecentTokens: () => 0, |
| applyOverrides: () => {}, |
| }); |
|
|
| const defaultPreemptiveCompaction = () => ({ |
| route: "fits" as const, |
| shouldCompact: false, |
| estimatedPromptTokens: 600, |
| promptBudgetBeforeReserve: 800, |
| overflowTokens: 0, |
| toolResultReducibleChars: 0, |
| effectiveReserveTokens: 200, |
| }); |
|
|
| function createPreparedRuntimeLease(input: { |
| config: OpenClawConfig; |
| agentDir: string; |
| agentId?: string; |
| workspaceDir?: string; |
| }) { |
| const prepared = createModelGenerationFixture({ |
| config: input.config, |
| label: "cli", |
| agentDir: input.agentDir, |
| workspaceDir: expectDefined(input.workspaceDir, "compaction fixture workspace"), |
| }); |
| return { |
| snapshot: { |
| ...prepared.preparedModelRuntime, |
| ...(input.agentId ? { agentId: input.agentId } : {}), |
| }, |
| pluginGeneration: { |
| configuredCatalogEntries: [], |
| inlineProviderModels: [], |
| pluginMetadataSnapshot: prepared.metadataSnapshot, |
| pluginRegistry: prepared.pluginRegistry, |
| }, |
| [Symbol.asyncDispose]: vi.fn(async () => {}), |
| }; |
| } |
|
|
| async function prepareCompactionScenario(params: { |
| tmpDir: string; |
| suffix: string; |
| provider?: string; |
| model?: string; |
| sessionKey?: string; |
| sessionId?: string; |
| sessionEntry?: Partial<SessionEntry>; |
| cfg?: OpenClawConfig; |
| cwd?: string; |
| contextEngine?: (compactCalls: CompactParams[]) => ContextEngine; |
| maintenance?: CliCompactionTestDeps["runContextEngineMaintenance"]; |
| recordCliCompactionInStore?: CliCompactionTestDeps["recordCliCompactionInStore"]; |
| deps?: CliCompactionTestDeps; |
| }) { |
| const sessionKey = params.sessionKey ?? `agent:main:${params.suffix}`; |
| const sessionId = params.sessionId ?? `session-${params.suffix}`; |
| const transcriptFile = path.join(params.tmpDir, `${params.suffix}.jsonl`); |
| const storePath = path.join(params.tmpDir, `${params.suffix}.sqlite`); |
| await writeSessionFile({ sessionFile: transcriptFile, sessionId }); |
|
|
| const sessionEntry: SessionEntry = { |
| sessionId, |
| updatedAt: Date.now(), |
| sessionFile: transcriptFile, |
| contextTokens: 1_000, |
| totalTokens: 950, |
| totalTokensFresh: true, |
| totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION, |
| ...params.sessionEntry, |
| }; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await persistSessionEntry({ sessionKey, storePath, entry: sessionEntry }); |
|
|
| const compactCalls: CompactParams[] = []; |
| const contextEngine = |
| params.contextEngine?.(compactCalls) ?? buildContextEngine({ compactCalls }); |
| const maintenance = vi.fn( |
| params.maintenance ?? (async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 })), |
| ); |
| const recordCliCompactionInStore = |
| params.recordCliCompactionInStore ?? vi.fn(recordCliCompactionInStoreImpl); |
| setCliCompactionTestDeps({ |
| resolveContextEngine: async () => contextEngine, |
| ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined), |
| createPreparedEmbeddedAgentSettingsManager: defaultSettingsManager, |
| shouldPreemptivelyCompactBeforePrompt: defaultPreemptiveCompaction, |
| resolveLiveToolResultMaxChars: () => 20_000, |
| runContextEngineMaintenance: maintenance, |
| recordCliCompactionInStore, |
| ...params.deps, |
| }); |
|
|
| const runParams: CliCompactionParams = { |
| cfg: params.cfg ?? ({} as OpenClawConfig), |
| sessionId, |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| storePath, |
| sessionAgentId: "main", |
| workspaceDir: params.tmpDir, |
| cwd: params.cwd, |
| agentDir: params.tmpDir, |
| provider: params.provider ?? "claude-cli", |
| model: params.model ?? "opus", |
| }; |
| return { |
| compactCalls, |
| contextEngine, |
| maintenance, |
| recordCliCompactionInStore, |
| run: (overrides: Partial<CliCompactionParams> = {}) => |
| runCliTurnCompactionLifecycle({ ...runParams, ...overrides }), |
| sessionEntry, |
| sessionId, |
| sessionKey, |
| sessionStore, |
| storePath, |
| transcriptFile, |
| }; |
| } |
|
|
| async function prepareContextSuccessorScenario(params: { |
| result: (target: { |
| sessionKey: string; |
| sessionId: string; |
| storePath: string; |
| }) => |
| | Awaited<ReturnType<ContextEngine["compact"]>> |
| | Promise<Awaited<ReturnType<ContextEngine["compact"]>>>; |
| suffix: string; |
| tmpDir: string; |
| }) { |
| return prepareCompactionScenario({ |
| suffix: `cli-successor-${params.suffix}`, |
| tmpDir: params.tmpDir, |
| contextEngine: () => ({ |
| ...buildContextEngine({ compactCalls: [] }), |
| async compact() { |
| const sessionKey = `agent:main:cli-successor-${params.suffix}`; |
| const sessionId = `session-cli-successor-${params.suffix}`; |
| const storePath = path.join(params.tmpDir, `cli-successor-${params.suffix}.sqlite`); |
| return params.result({ sessionId, sessionKey, storePath }); |
| }, |
| }), |
| recordCliCompactionInStore: vi.fn( |
| async ({ sessionKey, sessionStore }) => sessionStore[sessionKey], |
| ), |
| }); |
| } |
|
|
| describe("runCliTurnCompactionLifecycle", () => { |
| let tmpDir: string; |
|
|
| beforeEach(async () => { |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-compaction-")); |
| setCliCompactionTestDeps({ |
| resolveCliBackendConfig: () => null, |
| acquirePreparedModelRuntime: async (input) => createPreparedRuntimeLease(input), |
| }); |
| }); |
|
|
| afterEach(async () => { |
| resetCliCompactionTestDeps(); |
| vi.clearAllTimers(); |
| vi.useRealTimers(); |
| await fs.rm(tmpDir, { recursive: true, force: true }); |
| }); |
|
|
| it.each(["context", "native", "fallback", "failure", "stale"] as const)( |
| "retains one resolved engine through compaction and awaits its cleanup (%s)", |
| async (mode) => { |
| const registry = createEmptyPluginRegistry(); |
| const resources = new PluginRegistryInspectionResources(retireInspectionInstances); |
| resources.attach(registry); |
| const retire = vi.fn(); |
| resources.register("fixture", { id: "resource", dispose: retire }); |
| const cleanupStarted = createDeferred(); |
| const cleanupGate = createDeferred(); |
| const cleanupFile = path.join(tmpDir, `owned-${mode}-cleanup.txt`); |
| const controller = new AbortController(); |
| const staleError = new Error("compaction owner retired"); |
| const compactCalls: CompactParams[] = []; |
| const raw = { |
| ...buildContextEngine({ compactCalls }), |
| async dispose() { |
| cleanupStarted.resolve(); |
| await cleanupGate.promise; |
| await fs.writeFile(cleanupFile, "disposed", { signal: getAsyncWorkSignal() }); |
| if (mode === "failure") { |
| throw new Error("cleanup failed too"); |
| } |
| }, |
| }; |
| registerContextEngineInRegistry(registry, "legacy", () => raw, "core"); |
| let engine: ContextEngine | undefined; |
| const scenario = await prepareCompactionScenario({ |
| tmpDir, |
| suffix: `owned-${mode}`, |
| provider: "github-copilot", |
| sessionEntry: mode === "native" || mode === "fallback" ? { agentHarnessId: "copilot" } : {}, |
| maintenance: async () => { |
| expect(retire).not.toHaveBeenCalled(); |
| if (mode === "failure") { |
| throw new Error("maintenance failed"); |
| } |
| return { changed: false, bytesFreed: 0, rewrittenEntries: 0 }; |
| }, |
| deps: { |
| ensureContextEnginesInitialized: vi.fn(), |
| resolveContextEngine: async (cfg) => { |
| engine = await withPluginRuntimeRegistryScope(registry, () => |
| resolveContextEngineFromRegistry(cfg), |
| ); |
| if (mode === "stale") { |
| controller.abort(staleError); |
| } |
| return engine; |
| }, |
| maybeCompactAgentHarnessSession: async () => { |
| expect(retire).not.toHaveBeenCalled(); |
| return mode === "fallback" |
| ? { |
| ok: false, |
| compacted: false, |
| reason: "unsupported", |
| failure: { reason: "unsupported_harness_compaction" }, |
| } |
| : { ok: true, compacted: true }; |
| }, |
| }, |
| }); |
| const result = scenario.run({ abortSignal: controller.signal }).then( |
| () => undefined, |
| (error: unknown) => error, |
| ); |
| try { |
| expect( |
| await Promise.race([ |
| cleanupStarted.promise.then(() => "cleanup"), |
| result.then(() => "returned"), |
| ]), |
| ).toBe("cleanup"); |
| await resources.release(); |
| expect(retire).not.toHaveBeenCalled(); |
| cleanupGate.resolve(); |
| const error = await result; |
| if (mode === "failure") { |
| expect(error).toEqual(new Error("maintenance failed")); |
| } else { |
| expect(error).toBe(mode === "stale" ? staleError : undefined); |
| } |
| expect(compactCalls).toHaveLength(mode === "native" || mode === "stale" ? 0 : 1); |
| expect(await fs.readFile(cleanupFile, "utf8")).toBe("disposed"); |
| expect(retire).toHaveBeenCalledTimes(1); |
| } finally { |
| cleanupGate.resolve(); |
| await result; |
| await engine?.dispose?.().catch(() => {}); |
| await resources.release(); |
| } |
| }, |
| ); |
|
|
| it("retains an aborted compaction engine until its raw work and cleanup settle", async () => { |
| const registry = createEmptyPluginRegistry(); |
| const resources = new PluginRegistryInspectionResources(retireInspectionInstances); |
| resources.attach(registry); |
| const retired = createDeferred(); |
| const retire = vi.fn(() => retired.resolve()); |
| resources.register("fixture", { id: "resource", dispose: retire }); |
| const compactStarted = createDeferred(); |
| const compactGate = createDeferred(); |
| const cleanupStarted = createDeferred(); |
| const cleanupGate = createDeferred(); |
| const dispose = vi.fn(async () => { |
| cleanupStarted.resolve(); |
| await cleanupGate.promise; |
| }); |
| const engine = { |
| ...buildContextEngine({ compactCalls: [] }), |
| async compact() { |
| compactStarted.resolve(); |
| await compactGate.promise; |
| expect(retire).not.toHaveBeenCalled(); |
| return { ok: false, compacted: false, reason: "aborted" }; |
| }, |
| dispose, |
| }; |
| registerContextEngineInRegistry(registry, "legacy", () => engine, "core"); |
| let owned: ContextEngine | undefined; |
| const scenario = await prepareCompactionScenario({ |
| tmpDir, |
| suffix: "owned-abort-tail", |
| deps: { |
| ensureContextEnginesInitialized: vi.fn(), |
| resolveContextEngine: async (cfg) => { |
| owned = await withPluginRuntimeRegistryScope(registry, () => |
| resolveContextEngineFromRegistry(cfg), |
| ); |
| return owned; |
| }, |
| }, |
| }); |
| const controller = new AbortController(); |
| const result = scenario |
| .run({ abortSignal: controller.signal }) |
| .catch((error: unknown) => error); |
| try { |
| await compactStarted.promise; |
| controller.abort(new Error("compaction cancelled")); |
| expect(await result).toEqual( |
| expect.objectContaining({ message: expect.stringContaining("compaction cancelled") }), |
| ); |
| await resources.release(); |
| expect(dispose).not.toHaveBeenCalled(); |
| expect(retire).not.toHaveBeenCalled(); |
| compactGate.resolve(); |
| await cleanupStarted.promise; |
| expect(retire).not.toHaveBeenCalled(); |
| cleanupGate.resolve(); |
| await retired.promise; |
| expect(retire).toHaveBeenCalledTimes(1); |
| } finally { |
| compactGate.resolve(); |
| cleanupGate.resolve(); |
| await result; |
| await owned?.dispose?.(); |
| await resources.release(); |
| } |
| }); |
|
|
| it("ignores an unversioned fresh total on the first upgraded turn", async () => { |
| const scenario = await prepareCompactionScenario({ |
| suffix: "legacy-unversioned-total", |
| tmpDir, |
| sessionEntry: { totalTokens: 950, totalTokensFresh: true, totalTokensVersion: undefined }, |
| }); |
|
|
| const updatedEntry = await scenario.run(); |
|
|
| expect(scenario.compactCalls).toEqual([]); |
| expect(updatedEntry).toBe(scenario.sessionEntry); |
| }); |
|
|
| it("accepts no compactable entries only from a successful compaction result", async () => { |
| let result = { ok: true, compacted: false, reason: "no real conversation messages" }; |
| const scenario = await prepareCompactionScenario({ |
| suffix: "no-compactable-entries", |
| tmpDir, |
| provider: "test-provider", |
| model: "test-model", |
| contextEngine: () => ({ |
| ...buildContextEngine({ compactCalls: [] }), |
| async compact() { |
| return result; |
| }, |
| }), |
| deps: { |
| openSessionManager: () => |
| ({ getBranch: () => [], buildSessionContext: () => ({ messages: [] }) }) as never, |
| }, |
| }); |
| const runLifecycle = ( |
| ok: boolean, |
| reason = "no real conversation messages", |
| compacted = false, |
| ) => { |
| result = { ok, compacted, reason }; |
| return scenario.run(); |
| }; |
|
|
| await expect(runLifecycle(true)).resolves.toBe(scenario.sessionEntry); |
| await expect(runLifecycle(false)).rejects.toThrow( |
| "CLI transcript compaction failed for test-provider/test-model: no real conversation messages", |
| ); |
| await expect(runLifecycle(false, "already under target")).resolves.toBe(scenario.sessionEntry); |
| await expect(runLifecycle(false, "contradictory result", true)).rejects.toThrow( |
| "CLI transcript compaction failed for test-provider/test-model: contradictory result", |
| ); |
| }); |
|
|
| it("compacts over-budget CLI transcripts and clears external CLI resume state", async () => { |
| const taskCwd = path.join(tmpDir, "task-repo"); |
| await fs.mkdir(taskCwd, { recursive: true }); |
| const settingsCwds: string[] = []; |
| |
| |
| const scenario = await prepareCompactionScenario({ |
| suffix: "cli", |
| tmpDir, |
| sessionKey: "agent:main:cli", |
| cwd: taskCwd, |
| sessionEntry: { |
| sessionFile: "agent:main:cli", |
| cliSessionBindings: { "claude-cli": { sessionId: "claude-session" } }, |
| cliSessionIds: { "claude-cli": "claude-session" }, |
| claudeCliSessionId: "claude-session", |
| }, |
| deps: { |
| createPreparedEmbeddedAgentSettingsManager: async (params) => { |
| settingsCwds.push(params.cwd); |
| return defaultSettingsManager(); |
| }, |
| }, |
| }); |
| const { compactCalls, maintenance, sessionId, sessionKey, storePath } = scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactCalls).toHaveLength(1); |
| const compactCall = compactCalls[0]; |
| expect(compactCall?.sessionId).toBe(sessionId); |
| expect(compactCall?.sessionKey).toBe(sessionKey); |
| expect(compactCall?.sessionTarget).toEqual({ sessionId, sessionKey, storePath }); |
| expect(compactCall?.tokenBudget).toBe(1_000); |
| expect(compactCall?.currentTokenCount).toBe(950); |
| expect(compactCall?.force).toBe(true); |
| expect(compactCall?.compactionTarget).toBe("budget"); |
| expect(compactCall?.runtimeContext?.workspaceDir).toBe(tmpDir); |
| expect(compactCall?.runtimeContext?.cwd).toBe(taskCwd); |
| expect(settingsCwds).toEqual([taskCwd]); |
| expect(maintenance).toHaveBeenCalledTimes(1); |
| const maintenanceCalls = maintenance.mock.calls as unknown as Array< |
| [ |
| { |
| reason?: string; |
| sessionId?: string; |
| sessionKey?: string; |
| sessionFile?: string; |
| }, |
| ] |
| >; |
| const maintenanceCall = maintenanceCalls[0]?.[0]; |
| expect(maintenanceCall?.reason).toBe("compaction"); |
| expect(maintenanceCall?.sessionId).toBe(sessionId); |
| expect(maintenanceCall?.sessionKey).toBe(sessionKey); |
| expect(maintenanceCall?.sessionFile).toBe(sessionKey); |
| expect(updatedEntry?.compactionCount).toBe(1); |
| |
| |
| expect(updatedEntry?.cliSessionBindings?.["claude-cli"]).toBeUndefined(); |
| expect(updatedEntry?.cliSessionIds?.["claude-cli"]).toBeUndefined(); |
| expect(updatedEntry?.claudeCliSessionId).toBeUndefined(); |
| }); |
|
|
| it("records context-engine compaction successor session targets", async () => { |
| const successorSessionId = "session-cli-rotated"; |
| const recordCliCompactionInStore = vi.fn(recordCliCompactionInStoreImpl); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "cli-rotates", |
| tmpDir, |
| contextEngine: (compactCalls) => ({ |
| ...buildContextEngine({ compactCalls }), |
| async compact(compactParams) { |
| compactCalls.push(compactParams); |
| return { |
| ok: true, |
| compacted: true, |
| result: { |
| summary: "compacted", |
| tokensBefore: compactParams.currentTokenCount ?? 0, |
| tokensAfter: 100, |
| sessionId: successorSessionId, |
| sessionTarget: { |
| sessionKey: "agent:main:cli-rotates", |
| storePath: path.join(tmpDir, "cli-rotates.sqlite"), |
| }, |
| }, |
| }; |
| }, |
| }), |
| recordCliCompactionInStore, |
| }); |
| const { compactCalls, maintenance, sessionId, sessionKey, storePath } = scenario; |
| await scenario.run(); |
|
|
| expect(compactCalls[0]?.sessionTarget).toEqual({ sessionId, sessionKey, storePath }); |
| expect(maintenance).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| sessionId: successorSessionId, |
| sessionFile: sessionKey, |
| }), |
| ); |
| expect(recordCliCompactionInStore).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| compactionKind: "context-engine", |
| expectedSession: expect.objectContaining({ sessionId: successorSessionId }), |
| tokensAfter: 100, |
| }), |
| ); |
| }); |
|
|
| it("preserves deprecated SQLite-marker successors during CLI maintenance", async () => { |
| const successorId = "session-cli-marker-successor"; |
| const scenario = await prepareContextSuccessorScenario({ |
| suffix: "marker", |
| tmpDir, |
| result: ({ storePath }) => ({ |
| ok: true, |
| compacted: true, |
| result: { |
| tokensBefore: 950, |
| tokensAfter: 100, |
| sessionId: successorId, |
| sessionFile: `sqlite:main:${successorId}:${storePath}`, |
| }, |
| }), |
| }); |
|
|
| await scenario.run(); |
|
|
| expect(scenario.maintenance).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| sessionFile: `sqlite:main:${successorId}:${scenario.storePath}`, |
| sessionId: successorId, |
| sessionTarget: { |
| agentId: "main", |
| sessionId: successorId, |
| sessionKey: scenario.sessionKey, |
| storePath: scenario.storePath, |
| }, |
| }), |
| ); |
| expect(scenario.recordCliCompactionInStore).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| expectedSession: expect.objectContaining({ sessionId: successorId }), |
| }), |
| ); |
| }); |
|
|
| it("rejects an engine that changes the host row before successor acceptance", async () => { |
| const successorId = "session-cli-key-successor"; |
| const scenario = await prepareContextSuccessorScenario({ |
| suffix: "session-key", |
| tmpDir, |
| result: async ({ sessionKey, storePath }) => { |
| await persistSessionEntry({ |
| sessionKey, |
| storePath, |
| entry: { sessionId: successorId, updatedAt: Date.now() }, |
| }); |
| return { |
| ok: true, |
| compacted: true, |
| result: { |
| tokensBefore: 950, |
| sessionId: successorId, |
| sessionFile: sessionKey, |
| }, |
| }; |
| }, |
| }); |
|
|
| await expect(scenario.run()).rejects.toThrow(); |
| expect(scenario.maintenance).not.toHaveBeenCalled(); |
| expect(scenario.recordCliCompactionInStore).not.toHaveBeenCalled(); |
| }); |
|
|
| it("rejects conflicting CLI successor ids", async () => { |
| const scenario = await prepareContextSuccessorScenario({ |
| suffix: "conflicting-ids", |
| tmpDir, |
| result: ({ sessionKey, storePath }) => ({ |
| ok: true, |
| compacted: true, |
| result: { |
| tokensBefore: 950, |
| sessionId: "reported-successor", |
| sessionTarget: { |
| agentId: "main", |
| sessionId: "target-successor", |
| sessionKey, |
| storePath, |
| }, |
| }, |
| }), |
| }); |
|
|
| await expect(scenario.run()).rejects.toThrow("successor identity is inconsistent"); |
| }); |
|
|
| it.each([ |
| ["agent", () => ({ agentId: "other" })], |
| ["session key", () => ({ sessionKey: "agent:main:other" })], |
| ["store", () => ({ storePath: path.join(tmpDir, "other-openclaw-sessions.sqlite") })], |
| ])("rejects a CLI successor outside the active %s binding", async (label, buildOverride) => { |
| const scenario = await prepareContextSuccessorScenario({ |
| suffix: `outside-${label.replace(" ", "-")}`, |
| tmpDir, |
| result: ({ sessionKey, storePath }) => ({ |
| ok: true, |
| compacted: true, |
| result: { |
| tokensBefore: 950, |
| sessionTarget: { |
| agentId: "main", |
| sessionId: "outside-successor", |
| sessionKey, |
| storePath, |
| ...buildOverride(), |
| }, |
| }, |
| }), |
| }); |
|
|
| await expect(scenario.run()).rejects.toThrow( |
| "successor target changed the active session binding", |
| ); |
| }); |
|
|
| it("treats below-target CLI transcript compaction as a no-op", async () => { |
| const scenario = await prepareCompactionScenario({ |
| suffix: "cli-under-target", |
| tmpDir, |
| sessionEntry: { |
| sessionFile: "agent:main:cli-under-target", |
| cliSessionBindings: { "claude-cli": { sessionId: "claude-session" } }, |
| }, |
| contextEngine: (compactCalls) => ({ |
| ...buildContextEngine({ compactCalls }), |
| async compact(compactParams) { |
| compactCalls.push(compactParams); |
| return { |
| ok: true, |
| compacted: false, |
| reason: "already under target", |
| }; |
| }, |
| }), |
| }); |
| const { compactCalls, maintenance, recordCliCompactionInStore, sessionEntry } = scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactCalls).toHaveLength(1); |
| expect(maintenance).not.toHaveBeenCalled(); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| expect(updatedEntry).toBe(sessionEntry); |
| expect( |
| scenario.sessionStore[scenario.sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId, |
| ).toBe("claude-session"); |
| }); |
|
|
| it("treats already-compacted CLI transcript compaction as a no-op", async () => { |
| const scenario = await prepareCompactionScenario({ |
| suffix: "qwen-already-compacted", |
| tmpDir, |
| provider: "ollama", |
| model: "qwen3:14b", |
| contextEngine: (compactCalls) => ({ |
| ...buildContextEngine({ compactCalls }), |
| async compact(compactParams) { |
| compactCalls.push(compactParams); |
| throw new Error("Already compacted"); |
| }, |
| }), |
| }); |
| const { compactCalls, maintenance, recordCliCompactionInStore, sessionEntry } = scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactCalls).toHaveLength(1); |
| expect(maintenance).not.toHaveBeenCalled(); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| expect(updatedEntry).toBe(sessionEntry); |
| }); |
|
|
| it("routes OpenAI Codex harness CLI compaction through native harness compaction", async () => { |
| const compactCalls: CompactParams[] = []; |
| const contextEngine = buildContextEngine({ compactCalls }); |
| const resolveContextEngine = vi.fn(async () => contextEngine); |
| const preparedRuntimeLease = createPreparedRuntimeLease({ |
| config: {}, |
| agentId: "main", |
| agentDir: tmpDir, |
| workspaceDir: tmpDir, |
| }); |
| const pluginGeneration = { |
| configuredCatalogEntries: [], |
| inlineProviderModels: [], |
| pluginMetadataSnapshot: preparedRuntimeLease.snapshot.metadataSnapshot, |
| pluginRegistry: preparedRuntimeLease.snapshot.pluginRegistry, |
| } as never; |
| const acquirePreparedModelRuntime = vi.fn< |
| NonNullable<CliCompactionTestDeps["acquirePreparedModelRuntime"]> |
| >(async (_input, options) => { |
| expect(options?.pluginGeneration).toBe(pluginGeneration); |
| return preparedRuntimeLease; |
| }); |
| const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); |
| const compactAgentHarnessSession = vi.fn(async () => ({ |
| ok: true, |
| compacted: true, |
| result: { tokensBefore: 950, tokensAfter: 100 }, |
| })); |
| const applyAgentAutoCompactionGuard = vi.fn(async () => ({ |
| supported: true, |
| disabled: false, |
| })); |
| const recordCliCompactionInStore = vi.fn(async () => ({ |
| sessionId: "session-codex", |
| updatedAt: Date.now(), |
| compactionCount: 1, |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex", |
| tmpDir, |
| provider: "openai", |
| model: "gpt-5.5", |
| sessionEntry: { |
| agentHarnessId: "codex", |
| modelSelectionLocked: true, |
| authProfileOverride: "github-copilot:work", |
| authProfileOverrideSource: "auto", |
| }, |
| recordCliCompactionInStore, |
| deps: { |
| resolveContextEngine, |
| acquirePreparedModelRuntime, |
| ensureSelectedAgentHarnessPlugin, |
| maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, |
| applyAgentAutoCompactionGuard, |
| }, |
| }); |
| const { sessionId, sessionKey } = scenario; |
| const updatedEntry = await scenario.run({ pluginGeneration }); |
|
|
| expect(resolveContextEngine).toHaveBeenCalledTimes(1); |
| expect(applyAgentAutoCompactionGuard).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| contextEngineInfo: contextEngine.info, |
| }), |
| ); |
| expect(ensureSelectedAgentHarnessPlugin).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| provider: "openai", |
| modelId: "gpt-5.5", |
| sessionKey, |
| agentHarnessRuntimeOverride: "codex", |
| pluginRegistry: preparedRuntimeLease.snapshot.pluginRegistry, |
| }), |
| ); |
| expect(acquirePreparedModelRuntime).toHaveBeenCalledOnce(); |
| expect(applyAgentAutoCompactionGuard.mock.invocationCallOrder[0] ?? 0).toBeLessThan( |
| compactAgentHarnessSession.mock.invocationCallOrder[0] ?? 0, |
| ); |
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| const compactAgentHarnessSessionCalls = compactAgentHarnessSession.mock |
| .calls as unknown as Array<[Record<string, unknown>, { preparedModelRuntime?: unknown }]>; |
| expect(compactAgentHarnessSessionCalls[0]?.[0]).toMatchObject({ |
| sessionId, |
| sessionKey, |
| sessionFile: sessionKey, |
| provider: "openai", |
| model: "gpt-5.5", |
| contextTokenBudget: 1_000, |
| currentTokenCount: 950, |
| contextEngine, |
| agentHarnessId: "codex", |
| modelSelectionLocked: true, |
| authProfileId: "github-copilot:work", |
| trigger: "budget", |
| force: true, |
| }); |
| expect(compactAgentHarnessSessionCalls[0]?.[0].contextEngineRuntimeContext).toMatchObject({ |
| authProfileId: "github-copilot:work", |
| agentHarnessId: "codex", |
| modelSelectionLocked: true, |
| }); |
| expect(compactAgentHarnessSessionCalls[0]?.[1]?.preparedModelRuntime).toBe( |
| preparedRuntimeLease.snapshot, |
| ); |
| expect(preparedRuntimeLease[Symbol.asyncDispose]).toHaveBeenCalledOnce(); |
| expect(compactCalls).toHaveLength(0); |
| expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1); |
| expect(recordCliCompactionInStore).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| compactionKind: "native-harness", |
| sessionKey, |
| tokensAfter: 100, |
| }), |
| ); |
| expect(updatedEntry?.compactionCount).toBe(1); |
| }); |
|
|
| it("treats below-target Copilot native CLI compaction as a no-op", async () => { |
| const compactAgentHarnessSession = vi.fn(async () => ({ |
| ok: true, |
| compacted: false, |
| reason: "already under target", |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "copilot-under-target", |
| tmpDir, |
| provider: "github-copilot", |
| model: "gpt-5.5", |
| sessionEntry: { agentHarnessId: "copilot" }, |
| deps: { maybeCompactAgentHarnessSession: compactAgentHarnessSession as never }, |
| }); |
| const { compactCalls, recordCliCompactionInStore, sessionEntry } = scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| expect(compactCalls).toHaveLength(0); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| expect(updatedEntry).toBe(sessionEntry); |
| }); |
|
|
| it("ignores stale native harness ids when the active provider no longer matches", async () => { |
| const compactAgentHarnessSession = vi.fn(); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "openclaw-after-codex", |
| tmpDir, |
| provider: "openclaw", |
| model: "sonnet-4.6", |
| sessionEntry: { agentHarnessId: "codex" }, |
| deps: { maybeCompactAgentHarnessSession: compactAgentHarnessSession as never }, |
| }); |
| const { compactCalls, sessionEntry, sessionKey } = scenario; |
| await scenario.run(); |
|
|
| expect(compactAgentHarnessSession).not.toHaveBeenCalled(); |
| expect(compactCalls).toHaveLength(1); |
|
|
| const lockedEntry: SessionEntry = { ...sessionEntry, modelSelectionLocked: true }; |
| await expect( |
| scenario.run({ sessionEntry: lockedEntry, sessionStore: { [sessionKey]: lockedEntry } }), |
| ).rejects.toThrow("CLI compaction cannot replace a model-locked native harness runtime"); |
| expect(compactAgentHarnessSession).not.toHaveBeenCalled(); |
| expect(compactCalls).toHaveLength(1); |
| }); |
|
|
| it.each([ |
| { |
| name: "a normal failed result", |
| compacted: false, |
| reason: "timed out waiting for codex app-server compaction", |
| }, |
| { |
| name: "a contradictory compacted failure", |
| compacted: true, |
| reason: "contradictory native result", |
| }, |
| ])("surfaces nonrecoverable native harness CLI compaction failures for $name", async (result) => { |
| const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); |
| const compactAgentHarnessSession = vi.fn(async () => ({ |
| ok: false, |
| compacted: result.compacted, |
| reason: result.reason, |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex-native-failure", |
| tmpDir, |
| provider: "codex", |
| model: "gpt-5.5", |
| sessionEntry: { agentHarnessId: "codex" }, |
| deps: { |
| ensureSelectedAgentHarnessPlugin, |
| maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, |
| }, |
| }); |
| const { compactCalls, recordCliCompactionInStore } = scenario; |
|
|
| await expect(scenario.run()).rejects.toThrow( |
| `CLI native harness compaction failed for codex/gpt-5.5: ${result.reason}`, |
| ); |
|
|
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| expect(compactCalls).toHaveLength(0); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| }); |
|
|
| it("skips context-engine fallback when Codex owns automatic compaction", async () => { |
| const compactAgentHarnessSession = vi.fn(async (_params: Record<string, unknown>) => ({ |
| ok: true, |
| compacted: false, |
| reason: "codex app-server owns automatic compaction", |
| result: { |
| summary: "", |
| firstKeptEntryId: "", |
| tokensBefore: 950, |
| }, |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex-native-auto-compaction", |
| tmpDir, |
| provider: "codex", |
| model: "gpt-5.5", |
| sessionEntry: { agentHarnessId: "codex" }, |
| deps: { maybeCompactAgentHarnessSession: compactAgentHarnessSession as never }, |
| }); |
| const { compactCalls, recordCliCompactionInStore, sessionEntry, sessionKey, sessionStore } = |
| scenario; |
| const result = await scenario.run(); |
|
|
| |
| |
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| expect(compactCalls).toHaveLength(0); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| expect(result).toBe(sessionEntry); |
|
|
| const lockedEntry: SessionEntry = { ...sessionEntry, modelSelectionLocked: true }; |
| sessionStore[sessionKey] = lockedEntry; |
| const lockedResult = await scenario.run({ sessionEntry: lockedEntry }); |
|
|
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(2); |
| expect(compactCalls).toHaveLength(0); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| const lockedNativeCall = compactAgentHarnessSession.mock.calls[1]?.[0]; |
| expect(lockedNativeCall).toMatchObject({ |
| agentHarnessId: "codex", |
| modelSelectionLocked: true, |
| contextEngineRuntimeContext: expect.objectContaining({ |
| agentHarnessId: "codex", |
| modelSelectionLocked: true, |
| provider: "codex", |
| model: "gpt-5.5", |
| }), |
| }); |
| expect(lockedResult).toBe(lockedEntry); |
| }); |
|
|
| it("does not fall back when native harness compaction returns no result", async () => { |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex-native-empty", |
| tmpDir, |
| provider: "codex", |
| model: "gpt-5.5", |
| sessionEntry: { agentHarnessId: "codex" }, |
| deps: { maybeCompactAgentHarnessSession: vi.fn(async () => undefined) as never }, |
| }); |
|
|
| await expect(scenario.run()).rejects.toThrow( |
| "CLI native harness compaction failed for codex/gpt-5.5: native harness compaction did not reduce context", |
| ); |
| expect(scenario.compactCalls).toHaveLength(0); |
| }); |
|
|
| it("does not interpret a native harness result id as a host successor", async () => { |
| const compactCalls: CompactParams[] = []; |
| const contextEngine = { |
| ...buildContextEngine({ compactCalls }), |
| info: { |
| id: "lossless-claw", |
| name: "Lossless Claw", |
| ownsCompaction: true, |
| }, |
| } satisfies ContextEngine; |
| const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); |
| const compactAgentHarnessSession = vi.fn(async (compactParams) => { |
| expect(compactParams.contextEngine).toBe(contextEngine); |
| expect(compactParams.contextEngineRuntimeContext).toMatchObject({ |
| currentTokenCount: 950, |
| tokenBudget: 1_000, |
| trigger: "cli_native_budget", |
| }); |
| return { |
| ok: true, |
| compacted: true, |
| result: { |
| summary: "engine-owned", |
| firstKeptEntryId: "entry-1", |
| tokensBefore: 950, |
| tokensAfter: 42, |
| sessionId: "session-codex-owned-engine-rotated", |
| sessionFile: path.join(tmpDir, "session-codex-owned-engine-rotated.jsonl"), |
| }, |
| }; |
| }); |
| const recordCliCompactionInStore = vi.fn(async () => ({ |
| sessionId: "session-codex-owned-engine", |
| updatedAt: Date.now(), |
| compactionCount: 1, |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex-owned-engine", |
| tmpDir, |
| provider: "codex", |
| model: "gpt-5.5", |
| sessionEntry: { agentHarnessId: "codex" }, |
| recordCliCompactionInStore, |
| deps: { |
| resolveContextEngine: async () => contextEngine, |
| ensureSelectedAgentHarnessPlugin, |
| maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, |
| }, |
| }); |
| const { sessionKey } = scenario; |
| await scenario.run(); |
|
|
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| expect(recordCliCompactionInStore).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| sessionKey, |
| tokensAfter: 42, |
| expectedSession: expect.objectContaining({ sessionId: "session-codex-owned-engine" }), |
| }), |
| ); |
| }); |
|
|
| it("falls back to context-engine compaction when a pinned harness has no native compactor", async () => { |
| const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); |
| const compactAgentHarnessSession = vi.fn(async () => ({ |
| ok: false, |
| compacted: false, |
| reason: 'Agent harness "external-harness" does not support compaction.', |
| failure: { reason: "unsupported_harness_compaction" }, |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "external-harness", |
| tmpDir, |
| provider: "external-harness", |
| model: "model", |
| sessionEntry: { agentHarnessId: "external-harness" }, |
| deps: { |
| ensureSelectedAgentHarnessPlugin, |
| maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, |
| }, |
| }); |
| const { compactCalls, maintenance, recordCliCompactionInStore, sessionKey } = scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| expect(compactCalls).toHaveLength(1); |
| expect(maintenance).toHaveBeenCalledTimes(1); |
| expect(recordCliCompactionInStore).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| compactionKind: "context-engine", |
| sessionKey, |
| tokensAfter: 100, |
| }), |
| ); |
| expect(updatedEntry?.compactionCount).toBe(1); |
| }); |
|
|
| it("falls back to context-engine compaction when Codex native binding is stale", async () => { |
| const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); |
| const compactAgentHarnessSession = vi.fn(async () => ({ |
| ok: false, |
| compacted: false, |
| reason: "thread not found: thread-1", |
| failure: { |
| reason: "stale_thread_binding", |
| }, |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex-stale-binding", |
| tmpDir, |
| provider: "codex", |
| model: "gpt-5.5", |
| sessionEntry: { |
| agentHarnessId: "codex", |
| authProfileOverride: "github-copilot:work", |
| authProfileOverrideSource: "auto", |
| }, |
| deps: { |
| ensureSelectedAgentHarnessPlugin, |
| maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, |
| }, |
| }); |
| const { compactCalls, maintenance, recordCliCompactionInStore, sessionKey } = scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| expect(compactCalls).toHaveLength(1); |
| expect(compactCalls[0]?.runtimeContext).toMatchObject({ |
| authProfileId: "github-copilot:work", |
| }); |
| expect(maintenance).toHaveBeenCalledTimes(1); |
| expect(recordCliCompactionInStore).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| sessionKey, |
| tokensAfter: 100, |
| }), |
| ); |
| expect(updatedEntry?.compactionCount).toBe(1); |
| }); |
|
|
| it("clears stale Codex native binding when context-engine fallback is below target", async () => { |
| const compactAgentHarnessSession = vi.fn(async () => ({ |
| ok: false, |
| compacted: false, |
| reason: "thread not found: thread-1", |
| failure: { |
| reason: "stale_thread_binding", |
| }, |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex-stale-binding-under-target", |
| tmpDir, |
| provider: "codex", |
| model: "gpt-5.5", |
| sessionEntry: { |
| agentHarnessId: "codex", |
| cliSessionBindings: { codex: { sessionId: "thread-1" } }, |
| cliSessionIds: { codex: "thread-1" }, |
| }, |
| contextEngine: (compactCalls) => ({ |
| ...buildContextEngine({ compactCalls }), |
| async compact(compactParams) { |
| compactCalls.push(compactParams); |
| return { |
| ok: true, |
| compacted: false, |
| reason: "already under target", |
| }; |
| }, |
| }), |
| deps: { maybeCompactAgentHarnessSession: compactAgentHarnessSession as never }, |
| }); |
| const { compactCalls, maintenance, recordCliCompactionInStore, sessionKey, sessionStore } = |
| scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| expect(compactCalls).toHaveLength(1); |
| expect(maintenance).not.toHaveBeenCalled(); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| expect(updatedEntry?.compactionCount).toBeUndefined(); |
| expect(updatedEntry?.cliSessionBindings?.codex).toBeUndefined(); |
| expect(updatedEntry?.cliSessionIds?.codex).toBeUndefined(); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.codex).toBeUndefined(); |
| expect(sessionStore[sessionKey]?.cliSessionIds?.codex).toBeUndefined(); |
| }); |
|
|
| it("falls back to context-engine compaction when Codex native compaction returns a raw missing thread reason", async () => { |
| const compactAgentHarnessSession = vi.fn(async () => ({ |
| ok: false, |
| compacted: false, |
| reason: "thread not found: thread-raw", |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex-raw-stale-binding", |
| tmpDir, |
| provider: "codex", |
| model: "gpt-5.5", |
| sessionEntry: { agentHarnessId: "codex" }, |
| deps: { maybeCompactAgentHarnessSession: compactAgentHarnessSession as never }, |
| }); |
| const { compactCalls, maintenance, recordCliCompactionInStore, sessionKey } = scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| expect(compactCalls).toHaveLength(1); |
| expect(maintenance).toHaveBeenCalledTimes(1); |
| expect(recordCliCompactionInStore).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| sessionKey, |
| tokensAfter: 100, |
| }), |
| ); |
| expect(updatedEntry?.compactionCount).toBe(1); |
| }); |
|
|
| it("keeps successful context-engine fallback when post-compaction maintenance fails", async () => { |
| const maintenance = vi.fn(async () => { |
| throw new Error("maintenance rotated stale binding"); |
| }); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex-stale-maintenance", |
| tmpDir, |
| provider: "codex", |
| model: "gpt-5.5", |
| sessionEntry: { agentHarnessId: "codex" }, |
| maintenance, |
| deps: { |
| maybeCompactAgentHarnessSession: vi.fn(async () => ({ |
| ok: false, |
| compacted: false, |
| reason: "thread not found: thread-1", |
| failure: { reason: "stale_thread_binding" }, |
| })) as never, |
| }, |
| }); |
| const { compactCalls, recordCliCompactionInStore, sessionKey } = scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactCalls).toHaveLength(1); |
| expect(maintenance).toHaveBeenCalledTimes(1); |
| expect(recordCliCompactionInStore).toHaveBeenCalledWith( |
| expect.objectContaining({ sessionKey }), |
| ); |
| expect(updatedEntry?.compactionCount).toBe(1); |
| }); |
|
|
| it("initializes built-in context engines before resolving CLI compaction engine", async () => { |
| const calls: string[] = []; |
| const scenario = await prepareCompactionScenario({ |
| suffix: "cli-init", |
| tmpDir, |
| sessionKey: "agent:main:cli", |
| sessionEntry: { sessionFile: undefined }, |
| deps: { |
| ensureContextEnginesInitialized: () => { |
| calls.push("ensure"); |
| }, |
| resolveContextEngine: async () => { |
| calls.push("resolve"); |
| return buildContextEngine({ compactCalls: [] }); |
| }, |
| }, |
| }); |
| await scenario.run(); |
|
|
| expect(calls).toEqual(["ensure", "resolve"]); |
| }); |
|
|
| it("bounds a hung CLI context-engine compaction and leaves resume state intact", async () => { |
| const scenario = await prepareCompactionScenario({ |
| suffix: "cli-timeout", |
| tmpDir, |
| sessionKey: "agent:main:cli", |
| cfg: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } } as OpenClawConfig, |
| sessionEntry: { |
| cliSessionBindings: { "claude-cli": { sessionId: "claude-session" } }, |
| cliSessionIds: { "claude-cli": "claude-session" }, |
| claudeCliSessionId: "claude-session", |
| }, |
| contextEngine: (compactCalls) => ({ |
| ...buildContextEngine({ compactCalls }), |
| async compact(compactParams) { |
| compactCalls.push(compactParams); |
| return await new Promise(() => {}); |
| }, |
| }), |
| }); |
| const { compactCalls, maintenance, recordCliCompactionInStore, sessionKey, sessionStore } = |
| scenario; |
|
|
| vi.useFakeTimers(); |
| const pending = scenario.run(); |
|
|
| const rejection = expect(pending).rejects.toThrow( |
| "CLI transcript compaction failed for claude-cli/opus: Compaction timed out", |
| ); |
| await vi.advanceTimersByTimeAsync(1_000); |
| await rejection; |
| vi.useRealTimers(); |
|
|
| expect(compactCalls).toHaveLength(1); |
| expect(compactCalls[0]?.abortSignal).toBeInstanceOf(AbortSignal); |
| expect(compactCalls[0]?.abortSignal?.aborted).toBe(true); |
| expect(maintenance).not.toHaveBeenCalled(); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe( |
| "claude-session", |
| ); |
| }); |
|
|
| it.each(cliCompactionBackendEntrypoints.map((entry) => [entry.provider, entry] as const))( |
| "preserves %s native history and resume binding under compaction pressure", |
| async (provider, entry) => { |
| const { pluginId } = entry; |
| const bundled = path.join(tmpDir, "bundled"); |
| const pluginRoot = path.join(bundled, pluginId); |
| await fs.mkdir(pluginRoot, { recursive: true }); |
| await fs.copyFile( |
| new URL(`../../../extensions/${pluginId}/openclaw.plugin.json`, import.meta.url), |
| path.join(pluginRoot, "openclaw.plugin.json"), |
| ); |
| await fs.writeFile( |
| path.join(pluginRoot, "setup-api.mjs"), |
| `export { default } from ${JSON.stringify(resolveRuntimeWorkerUrl(entry).href)};`, |
| ); |
| await fs.copyFile(path.join(pluginRoot, "setup-api.mjs"), path.join(pluginRoot, "index.mjs")); |
| const backend = withEnv({ OPENCLAW_BUNDLED_PLUGINS_DIR: bundled }, () => |
| resolveCliBackendConfig(provider), |
| ); |
| expect(backend).not.toBeNull(); |
| const compactAgentHarnessSession = vi.fn(); |
| const scenario = await prepareCompactionScenario({ |
| suffix: `${provider}-owns-compaction`, |
| tmpDir, |
| provider, |
| sessionEntry: { |
| cliSessionBindings: { [provider]: { sessionId: "native-session" } }, |
| }, |
| deps: { |
| maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, |
| resolveCliBackendConfig: () => backend, |
| }, |
| }); |
| const { compactCalls, recordCliCompactionInStore, sessionEntry } = scenario; |
| const updatedEntry = await scenario.run(); |
|
|
| expect(compactAgentHarnessSession).not.toHaveBeenCalled(); |
| expect(compactCalls).toHaveLength(0); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| expect(updatedEntry).toBe(sessionEntry); |
| expect(updatedEntry?.cliSessionBindings?.[provider]?.sessionId).toBe("native-session"); |
| }, |
| ); |
|
|
| it("does not skip compaction when backend does not declare ownsNativeCompaction", async () => { |
| const scenario = await prepareCompactionScenario({ |
| suffix: "generic-no-ownership", |
| tmpDir, |
| provider: "generic-backend", |
| model: "model", |
| deps: { |
| resolveCliBackendConfig: () => ({ |
| id: "generic-backend", |
| config: { command: "generic" }, |
| bundleMcp: false, |
| }), |
| }, |
| }); |
| await scenario.run(); |
|
|
| expect(scenario.compactCalls).toHaveLength(1); |
| }); |
|
|
| it("still uses native harness path when backend declares ownsNativeCompaction and has agentHarnessId", async () => { |
| const compactAgentHarnessSession = vi.fn(async () => ({ |
| ok: true, |
| compacted: true, |
| result: { tokensBefore: 950, tokensAfter: 100 }, |
| })); |
| const scenario = await prepareCompactionScenario({ |
| suffix: "codex-with-ownership", |
| tmpDir, |
| provider: "openai", |
| model: "gpt-5.5", |
| sessionEntry: { agentHarnessId: "codex" }, |
| deps: { |
| maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, |
| resolveCliBackendConfig: () => ({ |
| id: "codex", |
| config: { command: "codex" }, |
| bundleMcp: false, |
| ownsNativeCompaction: true, |
| }), |
| applyAgentAutoCompactionGuard: vi.fn(async () => ({ |
| supported: true, |
| disabled: false, |
| })), |
| }, |
| }); |
| const { compactCalls, recordCliCompactionInStore } = scenario; |
| await scenario.run(); |
|
|
| expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); |
| expect(compactCalls).toHaveLength(0); |
| expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("does not compact reset-discarded tool output in the post-turn lifecycle", async () => { |
| const sessionKey = "agent:main:reset-lifecycle-proof"; |
| const sessionId = "session-reset-lifecycle-proof"; |
| const storePath = path.join(tmpDir, "sessions-reset-lifecycle.sqlite"); |
| const sessionEntry: SessionEntry = { |
| sessionId, |
| updatedAt: Date.now(), |
| contextTokens: 4_096, |
| totalTokens: 400, |
| totalTokensFresh: true, |
| totalTokensVersion: SESSION_TOTAL_TOKENS_VERSION, |
| }; |
| const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry }; |
| await persistSessionEntry({ sessionKey, storePath, entry: sessionEntry }); |
|
|
| const bigOutput = "x".repeat(20_000); |
| const compactCalls: CompactParams[] = []; |
| const recordCliCompactionInStore = vi.fn(async () => sessionEntry); |
| setCliCompactionTestDeps({ |
| openSessionManager: () => |
| SessionManager.fromEntries([ |
| { |
| type: "session", |
| version: CURRENT_SESSION_VERSION, |
| id: sessionId, |
| timestamp: new Date(0).toISOString(), |
| cwd: tmpDir, |
| }, |
| { |
| type: "message", |
| id: "kept-user", |
| parentId: null, |
| timestamp: new Date(0).toISOString(), |
| message: { role: "user", content: "kept question", timestamp: 1 }, |
| }, |
| { |
| type: "message", |
| id: "kept-tool", |
| parentId: "kept-user", |
| timestamp: new Date(0).toISOString(), |
| message: { |
| role: "toolResult", |
| toolCallId: "call-1", |
| toolName: "bash", |
| content: [{ type: "text", text: bigOutput }], |
| isError: false, |
| timestamp: 2, |
| }, |
| }, |
| { |
| type: "message", |
| id: "kept-assistant", |
| parentId: "kept-tool", |
| timestamp: new Date(0).toISOString(), |
| message: { |
| role: "assistant", |
| content: [{ type: "text", text: "kept answer" }], |
| provider: "test-provider", |
| model: "test-model", |
| usage: { |
| input: 1, |
| output: 1, |
| cacheRead: 0, |
| cacheWrite: 0, |
| totalTokens: 2, |
| cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, |
| }, |
| stopReason: "stop", |
| timestamp: 3, |
| }, |
| }, |
| { |
| type: "reset", |
| id: "reset", |
| parentId: "kept-assistant", |
| timestamp: new Date(0).toISOString(), |
| reason: "new", |
| firstKeptEntryId: "kept-user", |
| }, |
| { |
| type: "message", |
| id: "new", |
| parentId: "reset", |
| timestamp: new Date(0).toISOString(), |
| message: { role: "user", content: "new turn after reset", timestamp: 4 }, |
| }, |
| ]), |
| resolveContextEngine: async () => buildContextEngine({ compactCalls }), |
| createPreparedEmbeddedAgentSettingsManager: async () => ({ |
| getCompactionReserveTokens: () => 512, |
| getCompactionKeepRecentTokens: () => 0, |
| applyOverrides: () => {}, |
| }), |
| resolveLiveToolResultMaxChars: () => 20_000, |
| recordCliCompactionInStore, |
| }); |
| const result = await runCliTurnCompactionLifecycle({ |
| cfg: {} as OpenClawConfig, |
| sessionId, |
| sessionKey, |
| sessionEntry, |
| sessionStore, |
| storePath, |
| sessionAgentId: "main", |
| workspaceDir: tmpDir, |
| agentDir: tmpDir, |
| provider: "claude-cli", |
| model: "opus", |
| }); |
|
|
| expect(result).toBe(sessionEntry); |
| expect(compactCalls).toEqual([]); |
| expect(recordCliCompactionInStore).not.toHaveBeenCalled(); |
| }); |
| }); |
| |
|
|