|
| 1 | +import fs from "node:fs/promises"; |
| 2 | +import os from "node:os"; |
| 3 | +import path from "node:path"; |
| 4 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 5 | +import { loadSessionStore, type SessionEntry } from "../config/sessions.js"; |
| 6 | +import type { OpenClawConfig } from "../config/types.openclaw.js"; |
| 7 | + |
| 8 | +const state = vi.hoisted(() => ({ |
| 9 | + cfg: undefined as OpenClawConfig | undefined, |
| 10 | + workspaceDir: undefined as string | undefined, |
| 11 | + agentDir: undefined as string | undefined, |
| 12 | + runAgentAttemptMock: vi.fn(), |
| 13 | + deliveryFreshEntries: [] as Array<SessionEntry | undefined>, |
| 14 | +})); |
| 15 | + |
| 16 | +vi.mock("../config/io.js", () => ({ |
| 17 | + getRuntimeConfig: () => state.cfg, |
| 18 | + readConfigFileSnapshotForWrite: async () => ({ snapshot: { valid: false } }), |
| 19 | +})); |
| 20 | + |
| 21 | +vi.mock("./agent-runtime-config.js", () => ({ |
| 22 | + resolveAgentRuntimeConfig: async () => ({ |
| 23 | + loadedRaw: state.cfg, |
| 24 | + sourceConfig: state.cfg, |
| 25 | + cfg: state.cfg, |
| 26 | + }), |
| 27 | +})); |
| 28 | + |
| 29 | +vi.mock("./agent-scope.js", async () => { |
| 30 | + const actual = await vi.importActual<typeof import("./agent-scope.js")>("./agent-scope.js"); |
| 31 | + return { |
| 32 | + ...actual, |
| 33 | + clearAutoFallbackPrimaryProbeSelection: vi.fn(), |
| 34 | + entryMatchesAutoFallbackPrimaryProbe: () => false, |
| 35 | + hasSessionAutoModelFallbackProvenance: () => false, |
| 36 | + listAgentIds: () => ["main"], |
| 37 | + markAutoFallbackPrimaryProbe: vi.fn(), |
| 38 | + resolveAutoFallbackPrimaryProbe: () => undefined, |
| 39 | + resolveAgentConfig: () => undefined, |
| 40 | + resolveAgentDir: () => state.agentDir ?? "/tmp/openclaw-agent", |
| 41 | + resolveDefaultAgentId: () => "main", |
| 42 | + resolveEffectiveModelFallbacks: () => undefined, |
| 43 | + resolveSessionAgentId: () => "main", |
| 44 | + resolveAgentWorkspaceDir: () => state.workspaceDir ?? "/tmp/openclaw-workspace", |
| 45 | + }; |
| 46 | +}); |
| 47 | + |
| 48 | +vi.mock("../plugins/manifest-contract-eligibility.js", () => ({ |
| 49 | + loadManifestMetadataSnapshot: () => ({ plugins: [] }), |
| 50 | +})); |
| 51 | + |
| 52 | +vi.mock("./model-catalog.js", () => ({ |
| 53 | + loadManifestModelCatalog: () => [], |
| 54 | +})); |
| 55 | + |
| 56 | +vi.mock("./harness/runtime-plugin.js", () => ({ |
| 57 | + ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined), |
| 58 | +})); |
| 59 | + |
| 60 | +vi.mock("./workspace.js", () => ({ |
| 61 | + ensureAgentWorkspace: vi.fn(async () => undefined), |
| 62 | +})); |
| 63 | + |
| 64 | +vi.mock("./auth-profiles/store.js", async () => { |
| 65 | + const actual = await vi.importActual<typeof import("./auth-profiles/store.js")>( |
| 66 | + "./auth-profiles/store.js", |
| 67 | + ); |
| 68 | + return { |
| 69 | + ...actual, |
| 70 | + ensureAuthProfileStore: () => ({ profiles: {} }), |
| 71 | + saveAuthProfileStore: vi.fn(), |
| 72 | + updateAuthProfileStoreWithLock: vi.fn(async () => ({ profiles: {} })), |
| 73 | + }; |
| 74 | +}); |
| 75 | + |
| 76 | +vi.mock("../acp/control-plane/manager.js", () => ({ |
| 77 | + getAcpSessionManager: () => ({ |
| 78 | + resolveSession: () => null, |
| 79 | + }), |
| 80 | +})); |
| 81 | + |
| 82 | +vi.mock("../skills/runtime/remote.js", () => ({ |
| 83 | + getRemoteSkillEligibility: () => ({ enabled: false, reason: "test" }), |
| 84 | +})); |
| 85 | + |
| 86 | +vi.mock("../skills/runtime/session-snapshot.js", () => ({ |
| 87 | + resolveReusableWorkspaceSkillSnapshot: () => ({ |
| 88 | + shouldRefresh: true, |
| 89 | + snapshot: { |
| 90 | + prompt: "", |
| 91 | + skills: [], |
| 92 | + resolvedSkills: [], |
| 93 | + version: 0, |
| 94 | + }, |
| 95 | + }), |
| 96 | +})); |
| 97 | + |
| 98 | +vi.mock("./exec-defaults.js", () => ({ |
| 99 | + canExecRequestNode: () => false, |
| 100 | +})); |
| 101 | + |
| 102 | +vi.mock("./model-fallback.js", () => ({ |
| 103 | + runWithModelFallback: async (params: { |
| 104 | + provider: string; |
| 105 | + model: string; |
| 106 | + run: (provider: string, model: string) => Promise<unknown>; |
| 107 | + }) => ({ |
| 108 | + result: await params.run(params.provider, params.model), |
| 109 | + provider: params.provider, |
| 110 | + model: params.model, |
| 111 | + attempts: [], |
| 112 | + }), |
| 113 | +})); |
| 114 | + |
| 115 | +vi.mock("./command/attempt-execution.runtime.js", async () => { |
| 116 | + const actual = await vi.importActual<typeof import("./command/attempt-execution.runtime.js")>( |
| 117 | + "./command/attempt-execution.runtime.js", |
| 118 | + ); |
| 119 | + return { |
| 120 | + ...actual, |
| 121 | + runAgentAttempt: (...args: unknown[]) => state.runAgentAttemptMock(...args), |
| 122 | + }; |
| 123 | +}); |
| 124 | + |
| 125 | +vi.mock("./command/cli-compaction.js", () => ({ |
| 126 | + runCliTurnCompactionLifecycle: async (params: { sessionEntry?: SessionEntry }) => |
| 127 | + params.sessionEntry, |
| 128 | +})); |
| 129 | + |
| 130 | +vi.mock("./command/delivery.runtime.js", () => ({ |
| 131 | + deliverAgentCommandResult: async (params: { |
| 132 | + resolveFreshSessionEntryForDelivery?: () => Promise<SessionEntry | undefined>; |
| 133 | + }) => { |
| 134 | + state.deliveryFreshEntries.push(await params.resolveFreshSessionEntryForDelivery?.()); |
| 135 | + return { deliverySucceeded: true }; |
| 136 | + }, |
| 137 | +})); |
| 138 | + |
| 139 | +let agentCommand: typeof import("./agent-command.js").agentCommand; |
| 140 | + |
| 141 | +beforeEach(async () => { |
| 142 | + vi.clearAllMocks(); |
| 143 | + state.deliveryFreshEntries = []; |
| 144 | + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rotation-e2e-")); |
| 145 | + state.workspaceDir = path.join(tmpDir, "workspace"); |
| 146 | + state.agentDir = path.join(tmpDir, "agent"); |
| 147 | + await fs.mkdir(state.workspaceDir, { recursive: true }); |
| 148 | + await fs.mkdir(state.agentDir, { recursive: true }); |
| 149 | + state.cfg = { |
| 150 | + session: { |
| 151 | + store: path.join(tmpDir, "sessions.json"), |
| 152 | + }, |
| 153 | + agents: { |
| 154 | + defaults: { |
| 155 | + models: { |
| 156 | + "openai/gpt-5.5": {}, |
| 157 | + }, |
| 158 | + }, |
| 159 | + }, |
| 160 | + } as OpenClawConfig; |
| 161 | + agentCommand ??= (await import("./agent-command.js")).agentCommand; |
| 162 | +}); |
| 163 | + |
| 164 | +afterEach(async () => { |
| 165 | + const storePath = state.cfg?.session?.store; |
| 166 | + state.cfg = undefined; |
| 167 | + state.workspaceDir = undefined; |
| 168 | + state.agentDir = undefined; |
| 169 | + if (storePath) { |
| 170 | + await fs.rm(path.dirname(storePath), { recursive: true, force: true }); |
| 171 | + } |
| 172 | +}); |
| 173 | + |
| 174 | +function makeResult(params: { |
| 175 | + sessionId: string; |
| 176 | + sessionFile?: string; |
| 177 | + text: string; |
| 178 | + compactionCount?: number; |
| 179 | +}) { |
| 180 | + return { |
| 181 | + payloads: [{ text: params.text }], |
| 182 | + meta: { |
| 183 | + durationMs: 1, |
| 184 | + stopReason: "end_turn", |
| 185 | + executionTrace: { |
| 186 | + runner: "embedded", |
| 187 | + fallbackUsed: false, |
| 188 | + winnerProvider: "openai", |
| 189 | + winnerModel: "gpt-5.5", |
| 190 | + }, |
| 191 | + finalAssistantVisibleText: params.text, |
| 192 | + agentMeta: { |
| 193 | + sessionId: params.sessionId, |
| 194 | + ...(params.sessionFile ? { sessionFile: params.sessionFile } : {}), |
| 195 | + provider: "openai", |
| 196 | + model: "gpt-5.5", |
| 197 | + ...(params.compactionCount ? { compactionCount: params.compactionCount } : {}), |
| 198 | + }, |
| 199 | + }, |
| 200 | + }; |
| 201 | +} |
| 202 | + |
| 203 | +async function readSessionMessages(sessionFile: string) { |
| 204 | + const raw = await fs.readFile(sessionFile, "utf-8"); |
| 205 | + return raw |
| 206 | + .split(/\r?\n/) |
| 207 | + .filter(Boolean) |
| 208 | + .map((line) => JSON.parse(line) as { type?: string; message?: { role?: string } }) |
| 209 | + .filter((entry) => entry.type === "message") |
| 210 | + .map((entry) => entry.message); |
| 211 | +} |
| 212 | + |
| 213 | +describe("agentCommand compaction transcript rotation", () => { |
| 214 | + it("keeps sessions.json on the rotated successor and resumes the next turn from it", async () => { |
| 215 | + const storePath = state.cfg?.session?.store; |
| 216 | + if (!storePath) { |
| 217 | + throw new Error("missing test session store path"); |
| 218 | + } |
| 219 | + const sessionsDir = await fs.realpath(path.dirname(storePath)); |
| 220 | + const rotatedSessionFile = path.join(sessionsDir, "rotated-session.jsonl"); |
| 221 | + state.runAgentAttemptMock |
| 222 | + .mockResolvedValueOnce( |
| 223 | + makeResult({ |
| 224 | + sessionId: "rotated-session", |
| 225 | + sessionFile: rotatedSessionFile, |
| 226 | + text: "first answer after rotation", |
| 227 | + compactionCount: 1, |
| 228 | + }), |
| 229 | + ) |
| 230 | + .mockResolvedValueOnce( |
| 231 | + makeResult({ |
| 232 | + sessionId: "rotated-session", |
| 233 | + text: "second answer", |
| 234 | + }), |
| 235 | + ); |
| 236 | + |
| 237 | + await agentCommand({ |
| 238 | + message: "first prompt", |
| 239 | + sessionId: "old-session", |
| 240 | + cwd: state.workspaceDir, |
| 241 | + }); |
| 242 | + |
| 243 | + const storeAfterRotation = loadSessionStore(storePath, { skipCache: true }); |
| 244 | + const entriesAfterRotation = Object.entries(storeAfterRotation); |
| 245 | + expect(entriesAfterRotation).toHaveLength(1); |
| 246 | + const [sessionKey, rotatedEntry] = entriesAfterRotation[0] ?? []; |
| 247 | + expect(sessionKey).toBe("agent:main:explicit:old-session"); |
| 248 | + expect(rotatedEntry).toMatchObject({ |
| 249 | + sessionId: "rotated-session", |
| 250 | + sessionFile: rotatedSessionFile, |
| 251 | + usageFamilyKey: "agent:main:explicit:old-session", |
| 252 | + usageFamilySessionIds: ["old-session", "rotated-session"], |
| 253 | + compactionCount: 1, |
| 254 | + }); |
| 255 | + await expect(readSessionMessages(rotatedSessionFile)).resolves.toEqual([ |
| 256 | + expect.objectContaining({ role: "assistant" }), |
| 257 | + ]); |
| 258 | + |
| 259 | + await agentCommand({ |
| 260 | + message: "second prompt", |
| 261 | + sessionId: "rotated-session", |
| 262 | + cwd: state.workspaceDir, |
| 263 | + }); |
| 264 | + |
| 265 | + const secondAttempt = state.runAgentAttemptMock.mock.calls[1]?.[0] as |
| 266 | + | { sessionId?: string; sessionFile?: string; sessionKey?: string } |
| 267 | + | undefined; |
| 268 | + expect(secondAttempt).toMatchObject({ |
| 269 | + sessionId: "rotated-session", |
| 270 | + sessionKey, |
| 271 | + sessionFile: rotatedSessionFile, |
| 272 | + }); |
| 273 | + expect(state.deliveryFreshEntries.at(-1)).toMatchObject({ |
| 274 | + sessionId: "rotated-session", |
| 275 | + sessionFile: rotatedSessionFile, |
| 276 | + }); |
| 277 | + expect(loadSessionStore(storePath, { skipCache: true })[sessionKey ?? ""]).toMatchObject({ |
| 278 | + sessionId: "rotated-session", |
| 279 | + sessionFile: rotatedSessionFile, |
| 280 | + }); |
| 281 | + }); |
| 282 | +}); |
0 commit comments