|
1 | 1 | import fs from "node:fs/promises"; |
2 | 2 | import path from "node:path"; |
3 | | -import { describe, expect, it } from "vitest"; |
| 3 | +import { afterEach, describe, expect, it } from "vitest"; |
| 4 | +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; |
4 | 5 | import { withTempDir } from "../test-helpers/temp-dir.js"; |
5 | | -import { createFileAcpEventLedger, createInMemoryAcpEventLedger } from "./event-ledger.js"; |
| 6 | +import { |
| 7 | + createFileAcpEventLedger, |
| 8 | + createInMemoryAcpEventLedger, |
| 9 | + createSqliteAcpEventLedger, |
| 10 | + migrateFileAcpEventLedgerToSqlite, |
| 11 | +} from "./event-ledger.js"; |
6 | 12 |
|
7 | 13 | describe("ACP event ledger", () => { |
| 14 | + afterEach(() => { |
| 15 | + closeOpenClawStateDatabaseForTest(); |
| 16 | + }); |
| 17 | + |
8 | 18 | it("records complete in-memory session updates in sequence", async () => { |
9 | 19 | const ledger = createInMemoryAcpEventLedger({ now: () => 123 }); |
10 | 20 | await ledger.startSession({ |
@@ -142,6 +152,123 @@ describe("ACP event ledger", () => { |
142 | 152 | }); |
143 | 153 | }); |
144 | 154 |
|
| 155 | + it("persists SQLite-backed replay state across ledger instances", async () => { |
| 156 | + await withTempDir({ prefix: "openclaw-acp-ledger-" }, async (dir) => { |
| 157 | + const databasePath = path.join(dir, "openclaw.sqlite"); |
| 158 | + const first = createSqliteAcpEventLedger({ path: databasePath, now: () => 1000 }); |
| 159 | + await first.startSession({ |
| 160 | + sessionId: "session-1", |
| 161 | + sessionKey: "agent:main:work", |
| 162 | + cwd: "/work", |
| 163 | + complete: true, |
| 164 | + }); |
| 165 | + await first.recordUpdate({ |
| 166 | + sessionId: "session-1", |
| 167 | + sessionKey: "agent:main:work", |
| 168 | + runId: "run-1", |
| 169 | + update: { |
| 170 | + sessionUpdate: "agent_thought_chunk", |
| 171 | + content: { type: "text", text: "Thinking" }, |
| 172 | + }, |
| 173 | + }); |
| 174 | + |
| 175 | + closeOpenClawStateDatabaseForTest(); |
| 176 | + const second = createSqliteAcpEventLedger({ path: databasePath }); |
| 177 | + const replay = await second.readReplay({ |
| 178 | + sessionId: "session-1", |
| 179 | + sessionKey: "agent:main:work", |
| 180 | + }); |
| 181 | + |
| 182 | + expect(replay.complete).toBe(true); |
| 183 | + expect(replay.events).toHaveLength(1); |
| 184 | + expect(replay.events[0]?.update).toEqual({ |
| 185 | + sessionUpdate: "agent_thought_chunk", |
| 186 | + content: { type: "text", text: "Thinking" }, |
| 187 | + }); |
| 188 | + }); |
| 189 | + }); |
| 190 | + |
| 191 | + it("imports legacy file-backed replay state into SQLite", async () => { |
| 192 | + await withTempDir({ prefix: "openclaw-acp-ledger-" }, async (dir) => { |
| 193 | + const filePath = path.join(dir, "acp", "event-ledger.json"); |
| 194 | + const databasePath = path.join(dir, "openclaw.sqlite"); |
| 195 | + const legacy = createFileAcpEventLedger({ filePath, now: () => 1000 }); |
| 196 | + await legacy.startSession({ |
| 197 | + sessionId: "session-1", |
| 198 | + sessionKey: "agent:main:work", |
| 199 | + cwd: "/work", |
| 200 | + complete: true, |
| 201 | + }); |
| 202 | + await legacy.recordUpdate({ |
| 203 | + sessionId: "session-1", |
| 204 | + sessionKey: "agent:main:work", |
| 205 | + runId: "run-1", |
| 206 | + update: { |
| 207 | + sessionUpdate: "agent_message_chunk", |
| 208 | + content: { type: "text", text: "Answer" }, |
| 209 | + }, |
| 210 | + }); |
| 211 | + |
| 212 | + const migrated = await migrateFileAcpEventLedgerToSqlite({ |
| 213 | + filePath, |
| 214 | + path: databasePath, |
| 215 | + archiveSource: true, |
| 216 | + }); |
| 217 | + const sqlite = createSqliteAcpEventLedger({ path: databasePath }); |
| 218 | + const replay = await sqlite.readReplay({ |
| 219 | + sessionId: "session-1", |
| 220 | + sessionKey: "agent:main:work", |
| 221 | + }); |
| 222 | + |
| 223 | + expect(migrated).toEqual({ |
| 224 | + importedSessions: 1, |
| 225 | + importedEvents: 1, |
| 226 | + archived: true, |
| 227 | + }); |
| 228 | + expect(replay.complete).toBe(true); |
| 229 | + expect(replay.events[0]?.update).toEqual({ |
| 230 | + sessionUpdate: "agent_message_chunk", |
| 231 | + content: { type: "text", text: "Answer" }, |
| 232 | + }); |
| 233 | + await expect(fs.stat(`${filePath}.migrated`)).resolves.toBeTruthy(); |
| 234 | + }); |
| 235 | + }); |
| 236 | + |
| 237 | + it("marks SQLite-backed replay incomplete when event retention truncates history", async () => { |
| 238 | + await withTempDir({ prefix: "openclaw-acp-ledger-" }, async (dir) => { |
| 239 | + const ledger = createSqliteAcpEventLedger({ |
| 240 | + path: path.join(dir, "openclaw.sqlite"), |
| 241 | + maxEventsPerSession: 1, |
| 242 | + }); |
| 243 | + await ledger.startSession({ |
| 244 | + sessionId: "session-1", |
| 245 | + sessionKey: "agent:main:work", |
| 246 | + cwd: "/work", |
| 247 | + complete: true, |
| 248 | + }); |
| 249 | + await ledger.recordUpdate({ |
| 250 | + sessionId: "session-1", |
| 251 | + sessionKey: "agent:main:work", |
| 252 | + update: { |
| 253 | + sessionUpdate: "agent_message_chunk", |
| 254 | + content: { type: "text", text: "First" }, |
| 255 | + }, |
| 256 | + }); |
| 257 | + await ledger.recordUpdate({ |
| 258 | + sessionId: "session-1", |
| 259 | + sessionKey: "agent:main:work", |
| 260 | + update: { |
| 261 | + sessionUpdate: "agent_message_chunk", |
| 262 | + content: { type: "text", text: "Second" }, |
| 263 | + }, |
| 264 | + }); |
| 265 | + |
| 266 | + await expect( |
| 267 | + ledger.readReplay({ sessionId: "session-1", sessionKey: "agent:main:work" }), |
| 268 | + ).resolves.toEqual({ complete: false, events: [] }); |
| 269 | + }); |
| 270 | + }); |
| 271 | + |
145 | 272 | it("can replay a complete session by Gateway session key", async () => { |
146 | 273 | const ledger = createInMemoryAcpEventLedger({ now: () => 1000 }); |
147 | 274 | await ledger.startSession({ |
|
0 commit comments