diff --git a/src/gateway/server-startup-session-migration.test.ts b/src/gateway/server-startup-session-migration.test.ts index 39c3814d83f8..fda3c3345169 100644 --- a/src/gateway/server-startup-session-migration.test.ts +++ b/src/gateway/server-startup-session-migration.test.ts @@ -1,10 +1,7 @@ /** * Gateway startup session migration tests. */ -import fs from "node:fs"; -import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { withTempDir } from "../test-helpers/temp-dir.js"; import { runStartupSessionMigration } from "./server-startup-session-migration.js"; function makeLog() { @@ -29,47 +26,6 @@ function firstLogMessage(log: ReturnType, label: string): string { } describe("runStartupSessionMigration", () => { - it("discovers plugin-owned agents during direct gateway startup", async () => { - await withTempDir({ prefix: "openclaw-startup-migration-" }, async (tempDir) => { - const storeTemplate = path.join(tempDir, "stores", "{agentId}", "sessions.json"); - const voiceStorePath = path.join(tempDir, "stores", "voice", "sessions.json"); - fs.mkdirSync(path.dirname(voiceStorePath), { recursive: true }); - fs.writeFileSync( - voiceStorePath, - JSON.stringify({ - "voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1 }, - }), - ); - const cfg = { - session: { store: storeTemplate }, - agents: { list: [{ id: "main", default: true }] }, - plugins: { - entries: { "voice-call": { config: { agentId: "voice" } } }, - }, - } as ReturnType; - const log = makeLog(); - - await runStartupSessionMigration({ - cfg, - env: { - ...process.env, - HOME: tempDir, - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - OPENCLAW_STATE_DIR: path.join(tempDir, "state"), - }, - log, - }); - - const store = JSON.parse(fs.readFileSync(voiceStorePath, "utf8")) as Record< - string, - { sessionId?: string } - >; - expect(store["agent:voice:voice:15550001111"]?.sessionId).toBe("legacy-voice"); - expect(store["voice:15550001111"]).toBeUndefined(); - expect(log.info).toHaveBeenCalledOnce(); - }); - }); - it("logs changes when orphaned keys are canonicalized", async () => { const log = makeLog(); const migrate = vi.fn().mockResolvedValue({ diff --git a/src/infra/state-migrations.orphan-keys.test.ts b/src/infra/state-migrations.orphan-keys.test.ts index 1b94511034b2..f9d0986a2aff 100644 --- a/src/infra/state-migrations.orphan-keys.test.ts +++ b/src/infra/state-migrations.orphan-keys.test.ts @@ -1,7 +1,7 @@ // Tests migration cleanup for orphaned state keys. import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { @@ -9,6 +9,16 @@ import { sessionStoreTextMayNeedCanonicalization, } from "./state-migrations.js"; +const listPluginDoctorSessionStoreAgentIdsMock = vi.hoisted(() => vi.fn((): string[] => [])); + +vi.mock("../plugins/doctor-contract-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listPluginDoctorSessionStoreAgentIds: listPluginDoctorSessionStoreAgentIdsMock, + }; +}); + function writeStore(storePath: string, store: Record): void { fs.mkdirSync(path.dirname(storePath), { recursive: true }); fs.writeFileSync(storePath, JSON.stringify(store)); @@ -55,14 +65,24 @@ function sharedMainOpsConfig(sharedStorePath: string): OpenClawConfig { } as OpenClawConfig; } -async function migrateFixtureState(stateDir: string, cfg: OpenClawConfig = OPS_WORK_CONFIG) { +async function migrateFixtureState( + stateDir: string, + cfg: OpenClawConfig = OPS_WORK_CONFIG, + additionalAgentIds?: readonly string[], +) { return migrateOrphanedSessionKeys({ cfg, env: { OPENCLAW_STATE_DIR: stateDir }, + additionalAgentIds, }); } describe("migrateOrphanedSessionKeys", () => { + beforeEach(() => { + listPluginDoctorSessionStoreAgentIdsMock.mockReset(); + listPluginDoctorSessionStoreAgentIdsMock.mockReturnValue([]); + }); + it("recognizes canonical stores without parsing them for migration", () => { const raw = JSON.stringify({ "agent:main:discord:channel:123": { sessionId: "channel", updatedAt: 1 }, @@ -238,6 +258,7 @@ describe("migrateOrphanedSessionKeys", () => { const result = await migrateOrphanedSessionKeys({ cfg, env: { OPENCLAW_STATE_DIR: stateDir }, + additionalAgentIds: ["voice"], }); const store = readStore(voiceStorePath); @@ -254,6 +275,41 @@ describe("migrateOrphanedSessionKeys", () => { }); }); + it("discovers plugin-owned agents through doctor contracts", async () => { + await withStateFixture(async ({ tmpDir, stateDir }) => { + listPluginDoctorSessionStoreAgentIdsMock.mockReturnValue(["voice"]); + const storeTemplate = path.join(tmpDir, "stores", "{agentId}", "sessions.json"); + const voiceStorePath = path.join(tmpDir, "stores", "voice", "sessions.json"); + writeStore(voiceStorePath, { + "voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 }, + }); + const cfg = { + session: { store: storeTemplate }, + agents: { list: [{ id: "main", default: true }] }, + plugins: { + entries: { + "voice-call": { config: { agentId: "voice" } }, + }, + }, + } as OpenClawConfig; + + const result = await migrateFixtureState(stateDir, cfg); + + expect(listPluginDoctorSessionStoreAgentIdsMock).toHaveBeenCalledWith({ + config: cfg, + env: { OPENCLAW_STATE_DIR: stateDir }, + pluginIds: ["voice-call"], + }); + const store = readStore(voiceStorePath); + expect(requireStoreEntry(store, "agent:voice:voice:15550001111").sessionId).toBe( + "legacy-voice", + ); + expect(store["voice:15550001111"]).toBeUndefined(); + expect(result.changes).toHaveLength(1); + expect(result.warnings).toHaveLength(0); + }); + }); + it.each([ { scope: undefined, canonicalMainKey: "agent:voice:main" }, { scope: "global" as const, canonicalMainKey: "global" }, @@ -278,7 +334,7 @@ describe("migrateOrphanedSessionKeys", () => { }, } as OpenClawConfig; - const result = await migrateFixtureState(stateDir, cfg); + const result = await migrateFixtureState(stateDir, cfg, ["voice"]); const store = readStore(voiceStorePath); expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("explicit-foreign"); @@ -310,7 +366,7 @@ describe("migrateOrphanedSessionKeys", () => { }, } as OpenClawConfig; - const result = await migrateFixtureState(stateDir, cfg); + const result = await migrateFixtureState(stateDir, cfg, ["voice"]); const store = readStore(sharedStorePath); expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("ambiguous-main"); @@ -336,7 +392,7 @@ describe("migrateOrphanedSessionKeys", () => { }, } as OpenClawConfig; - const result = await migrateFixtureState(stateDir, cfg); + const result = await migrateFixtureState(stateDir, cfg, ["voice"]); const store = readStore(sharedStorePath); expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("ambiguous-main"); @@ -370,8 +426,8 @@ describe("migrateOrphanedSessionKeys", () => { }, } as OpenClawConfig; - const result = await migrateFixtureState(stateDir, cfg); - const rerun = await migrateFixtureState(stateDir, cfg); + const result = await migrateFixtureState(stateDir, cfg, ["voice"]); + const rerun = await migrateFixtureState(stateDir, cfg, ["voice"]); expect(result.changes).toHaveLength(0); expect(result.warnings).toEqual([ @@ -457,7 +513,7 @@ describe("migrateOrphanedSessionKeys", () => { }, } as OpenClawConfig; - const result = await migrateFixtureState(stateDir, cfg); + const result = await migrateFixtureState(stateDir, cfg, ["voice"]); expect(result.changes).toHaveLength(0); expect(result.warnings).toEqual([ diff --git a/src/plugins/doctor-contract-registry.load-paths.test.ts b/src/plugins/doctor-contract-registry.load-paths.test.ts index 13c9076b0224..87ead40fe0f2 100644 --- a/src/plugins/doctor-contract-registry.load-paths.test.ts +++ b/src/plugins/doctor-contract-registry.load-paths.test.ts @@ -10,9 +10,11 @@ import { clearPluginDoctorContractRegistryCache, listPluginDoctorLegacyConfigRules, listPluginDoctorSessionRouteStateOwners, + listPluginDoctorSessionStoreAgentIds, } from "./doctor-contract-registry.js"; const tempDirs: string[] = []; +const repoRoot = path.resolve(import.meta.dirname, "../.."); function makeTempDir(): string { const dir = fs.mkdtempSync( @@ -306,4 +308,34 @@ describe("doctor contract registry load-path plugins", () => { }, ]); }); + + it("loads session-store agent IDs from the real Voice Call doctor contract", () => { + const stateDir = makeTempDir(); + const pluginRoot = path.join(repoRoot, "extensions", "voice-call"); + const config = { + plugins: { + load: { paths: [pluginRoot] }, + entries: { + "voice-call": { + enabled: true, + config: { + agentId: "Voice", + numbers: { + "+15550001111": { agentId: "Cards" }, + "+15550002222": {}, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect( + listPluginDoctorSessionStoreAgentIds({ + config, + env: makeHermeticDoctorEnv(stateDir), + pluginIds: ["voice-call"], + }), + ).toEqual(["cards", "voice"]); + }); });