11// Tests migration cleanup for orphaned state keys.
22import fs from "node:fs" ;
33import path from "node:path" ;
4- import { describe , expect , it , vi } from "vitest" ;
4+ import { beforeEach , describe , expect , it , vi } from "vitest" ;
55import type { OpenClawConfig } from "../config/config.js" ;
66import { withTempDir } from "../test-helpers/temp-dir.js" ;
77import {
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+
1222function 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
6580describe ( "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 ( [
0 commit comments