Skip to content

Commit 08d15ec

Browse files
authored
test: stabilize startup session migration flake (#97370)
* test: stabilize startup session migration flake * test: cover voice call doctor session ids
1 parent 39c4c02 commit 08d15ec

3 files changed

Lines changed: 96 additions & 52 deletions

File tree

src/gateway/server-startup-session-migration.test.ts

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
/**
22
* Gateway startup session migration tests.
33
*/
4-
import fs from "node:fs";
5-
import path from "node:path";
64
import { describe, expect, it, vi } from "vitest";
7-
import { withTempDir } from "../test-helpers/temp-dir.js";
85
import { runStartupSessionMigration } from "./server-startup-session-migration.js";
96

107
function makeLog() {
@@ -29,47 +26,6 @@ function firstLogMessage(log: ReturnType<typeof vi.fn>, label: string): string {
2926
}
3027

3128
describe("runStartupSessionMigration", () => {
32-
it("discovers plugin-owned agents during direct gateway startup", async () => {
33-
await withTempDir({ prefix: "openclaw-startup-migration-" }, async (tempDir) => {
34-
const storeTemplate = path.join(tempDir, "stores", "{agentId}", "sessions.json");
35-
const voiceStorePath = path.join(tempDir, "stores", "voice", "sessions.json");
36-
fs.mkdirSync(path.dirname(voiceStorePath), { recursive: true });
37-
fs.writeFileSync(
38-
voiceStorePath,
39-
JSON.stringify({
40-
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1 },
41-
}),
42-
);
43-
const cfg = {
44-
session: { store: storeTemplate },
45-
agents: { list: [{ id: "main", default: true }] },
46-
plugins: {
47-
entries: { "voice-call": { config: { agentId: "voice" } } },
48-
},
49-
} as ReturnType<typeof makeCfg>;
50-
const log = makeLog();
51-
52-
await runStartupSessionMigration({
53-
cfg,
54-
env: {
55-
...process.env,
56-
HOME: tempDir,
57-
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
58-
OPENCLAW_STATE_DIR: path.join(tempDir, "state"),
59-
},
60-
log,
61-
});
62-
63-
const store = JSON.parse(fs.readFileSync(voiceStorePath, "utf8")) as Record<
64-
string,
65-
{ sessionId?: string }
66-
>;
67-
expect(store["agent:voice:voice:15550001111"]?.sessionId).toBe("legacy-voice");
68-
expect(store["voice:15550001111"]).toBeUndefined();
69-
expect(log.info).toHaveBeenCalledOnce();
70-
});
71-
});
72-
7329
it("logs changes when orphaned keys are canonicalized", async () => {
7430
const log = makeLog();
7531
const migrate = vi.fn().mockResolvedValue({

src/infra/state-migrations.orphan-keys.test.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
// Tests migration cleanup for orphaned state keys.
22
import fs from "node:fs";
33
import path from "node:path";
4-
import { describe, expect, it, vi } from "vitest";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import type { OpenClawConfig } from "../config/config.js";
66
import { withTempDir } from "../test-helpers/temp-dir.js";
77
import {
88
migrateOrphanedSessionKeys,
99
sessionStoreTextMayNeedCanonicalization,
1010
} from "./state-migrations.js";
1111

12+
const listPluginDoctorSessionStoreAgentIdsMock = vi.hoisted(() => vi.fn((): string[] => []));
13+
14+
vi.mock("../plugins/doctor-contract-registry.js", async (importOriginal) => {
15+
const actual = await importOriginal<typeof import("../plugins/doctor-contract-registry.js")>();
16+
return {
17+
...actual,
18+
listPluginDoctorSessionStoreAgentIds: listPluginDoctorSessionStoreAgentIdsMock,
19+
};
20+
});
21+
1222
function writeStore(storePath: string, store: Record<string, unknown>): void {
1323
fs.mkdirSync(path.dirname(storePath), { recursive: true });
1424
fs.writeFileSync(storePath, JSON.stringify(store));
@@ -55,14 +65,24 @@ function sharedMainOpsConfig(sharedStorePath: string): OpenClawConfig {
5565
} as OpenClawConfig;
5666
}
5767

58-
async function migrateFixtureState(stateDir: string, cfg: OpenClawConfig = OPS_WORK_CONFIG) {
68+
async function migrateFixtureState(
69+
stateDir: string,
70+
cfg: OpenClawConfig = OPS_WORK_CONFIG,
71+
additionalAgentIds?: readonly string[],
72+
) {
5973
return migrateOrphanedSessionKeys({
6074
cfg,
6175
env: { OPENCLAW_STATE_DIR: stateDir },
76+
additionalAgentIds,
6277
});
6378
}
6479

6580
describe("migrateOrphanedSessionKeys", () => {
81+
beforeEach(() => {
82+
listPluginDoctorSessionStoreAgentIdsMock.mockReset();
83+
listPluginDoctorSessionStoreAgentIdsMock.mockReturnValue([]);
84+
});
85+
6686
it("recognizes canonical stores without parsing them for migration", () => {
6787
const raw = JSON.stringify({
6888
"agent:main:discord:channel:123": { sessionId: "channel", updatedAt: 1 },
@@ -238,6 +258,7 @@ describe("migrateOrphanedSessionKeys", () => {
238258
const result = await migrateOrphanedSessionKeys({
239259
cfg,
240260
env: { OPENCLAW_STATE_DIR: stateDir },
261+
additionalAgentIds: ["voice"],
241262
});
242263

243264
const store = readStore(voiceStorePath);
@@ -254,6 +275,41 @@ describe("migrateOrphanedSessionKeys", () => {
254275
});
255276
});
256277

278+
it("discovers plugin-owned agents through doctor contracts", async () => {
279+
await withStateFixture(async ({ tmpDir, stateDir }) => {
280+
listPluginDoctorSessionStoreAgentIdsMock.mockReturnValue(["voice"]);
281+
const storeTemplate = path.join(tmpDir, "stores", "{agentId}", "sessions.json");
282+
const voiceStorePath = path.join(tmpDir, "stores", "voice", "sessions.json");
283+
writeStore(voiceStorePath, {
284+
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
285+
});
286+
const cfg = {
287+
session: { store: storeTemplate },
288+
agents: { list: [{ id: "main", default: true }] },
289+
plugins: {
290+
entries: {
291+
"voice-call": { config: { agentId: "voice" } },
292+
},
293+
},
294+
} as OpenClawConfig;
295+
296+
const result = await migrateFixtureState(stateDir, cfg);
297+
298+
expect(listPluginDoctorSessionStoreAgentIdsMock).toHaveBeenCalledWith({
299+
config: cfg,
300+
env: { OPENCLAW_STATE_DIR: stateDir },
301+
pluginIds: ["voice-call"],
302+
});
303+
const store = readStore(voiceStorePath);
304+
expect(requireStoreEntry(store, "agent:voice:voice:15550001111").sessionId).toBe(
305+
"legacy-voice",
306+
);
307+
expect(store["voice:15550001111"]).toBeUndefined();
308+
expect(result.changes).toHaveLength(1);
309+
expect(result.warnings).toHaveLength(0);
310+
});
311+
});
312+
257313
it.each([
258314
{ scope: undefined, canonicalMainKey: "agent:voice:main" },
259315
{ scope: "global" as const, canonicalMainKey: "global" },
@@ -278,7 +334,7 @@ describe("migrateOrphanedSessionKeys", () => {
278334
},
279335
} as OpenClawConfig;
280336

281-
const result = await migrateFixtureState(stateDir, cfg);
337+
const result = await migrateFixtureState(stateDir, cfg, ["voice"]);
282338

283339
const store = readStore(voiceStorePath);
284340
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("explicit-foreign");
@@ -310,7 +366,7 @@ describe("migrateOrphanedSessionKeys", () => {
310366
},
311367
} as OpenClawConfig;
312368

313-
const result = await migrateFixtureState(stateDir, cfg);
369+
const result = await migrateFixtureState(stateDir, cfg, ["voice"]);
314370

315371
const store = readStore(sharedStorePath);
316372
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("ambiguous-main");
@@ -336,7 +392,7 @@ describe("migrateOrphanedSessionKeys", () => {
336392
},
337393
} as OpenClawConfig;
338394

339-
const result = await migrateFixtureState(stateDir, cfg);
395+
const result = await migrateFixtureState(stateDir, cfg, ["voice"]);
340396

341397
const store = readStore(sharedStorePath);
342398
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("ambiguous-main");
@@ -370,8 +426,8 @@ describe("migrateOrphanedSessionKeys", () => {
370426
},
371427
} as OpenClawConfig;
372428

373-
const result = await migrateFixtureState(stateDir, cfg);
374-
const rerun = await migrateFixtureState(stateDir, cfg);
429+
const result = await migrateFixtureState(stateDir, cfg, ["voice"]);
430+
const rerun = await migrateFixtureState(stateDir, cfg, ["voice"]);
375431

376432
expect(result.changes).toHaveLength(0);
377433
expect(result.warnings).toEqual([
@@ -457,7 +513,7 @@ describe("migrateOrphanedSessionKeys", () => {
457513
},
458514
} as OpenClawConfig;
459515

460-
const result = await migrateFixtureState(stateDir, cfg);
516+
const result = await migrateFixtureState(stateDir, cfg, ["voice"]);
461517

462518
expect(result.changes).toHaveLength(0);
463519
expect(result.warnings).toEqual([

src/plugins/doctor-contract-registry.load-paths.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import {
1010
clearPluginDoctorContractRegistryCache,
1111
listPluginDoctorLegacyConfigRules,
1212
listPluginDoctorSessionRouteStateOwners,
13+
listPluginDoctorSessionStoreAgentIds,
1314
} from "./doctor-contract-registry.js";
1415

1516
const tempDirs: string[] = [];
17+
const repoRoot = path.resolve(import.meta.dirname, "../..");
1618

1719
function makeTempDir(): string {
1820
const dir = fs.mkdtempSync(
@@ -306,4 +308,34 @@ describe("doctor contract registry load-path plugins", () => {
306308
},
307309
]);
308310
});
311+
312+
it("loads session-store agent IDs from the real Voice Call doctor contract", () => {
313+
const stateDir = makeTempDir();
314+
const pluginRoot = path.join(repoRoot, "extensions", "voice-call");
315+
const config = {
316+
plugins: {
317+
load: { paths: [pluginRoot] },
318+
entries: {
319+
"voice-call": {
320+
enabled: true,
321+
config: {
322+
agentId: "Voice",
323+
numbers: {
324+
"+15550001111": { agentId: "Cards" },
325+
"+15550002222": {},
326+
},
327+
},
328+
},
329+
},
330+
},
331+
} as OpenClawConfig;
332+
333+
expect(
334+
listPluginDoctorSessionStoreAgentIds({
335+
config,
336+
env: makeHermeticDoctorEnv(stateDir),
337+
pluginIds: ["voice-call"],
338+
}),
339+
).toEqual(["cards", "voice"]);
340+
});
309341
});

0 commit comments

Comments
 (0)