diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index b7b7df8e9d7..65028486e07 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -228,6 +228,30 @@ describe("doctor legacy state migrations", () => { expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e"); }); + it("keeps shipped WhatsApp legacy group keys channel-qualified during migration", async () => { + const root = await makeTempRoot(); + const cfg: OpenClawConfig = {}; + const targetDir = path.join(root, "agents", "main", "sessions"); + + writeLegacySessionsFixture({ + root, + sessions: { + "group:123@g.us": { sessionId: "wa", updatedAt: 10 }, + "group:abc": { sessionId: "generic", updatedAt: 9 }, + }, + }); + + const store = await runAndReadSessionsStore({ + root, + cfg, + targetDir, + now: () => 123, + }); + + expect(store["agent:main:whatsapp:group:123@g.us"]?.sessionId).toBe("wa"); + expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("generic"); + }); + it("migrates legacy agent dir with conflict fallback", async () => { const { root, cfg } = await makeRootWithEmptyCfg(); writeLegacyAgentFiles(root, { diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index cdfa4fa9396..77ba556b62f 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -55,7 +55,14 @@ async function createLegacyStateFixture(params?: { includePreKey?: boolean }) { await fs.writeFile(path.join(stateDir, "sessions", "trace.jsonl"), "{}\n", "utf8"); await fs.writeFile( path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json"), - `${JSON.stringify({ "group:123@g.us": { sessionId: "group-session", updatedAt: 5 } }, null, 2)}\n`, + `${JSON.stringify( + { + "group:123@g.us": { sessionId: "group-session", updatedAt: 5 }, + "group:legacy-room": { sessionId: "generic-group-session", updatedAt: 4 }, + }, + null, + 2, + )}\n`, "utf8", ); await fs.writeFile(path.join(stateDir, "agent", "settings.json"), '{"ok":true}\n', "utf8"); @@ -95,7 +102,7 @@ describe("state migrations", () => { expect(detected.targetAgentId).toBe("worker-1"); expect(detected.targetMainKey).toBe("desk"); expect(detected.sessions.hasLegacy).toBe(true); - expect(detected.sessions.legacyKeys).toEqual(["group:123@g.us"]); + expect(detected.sessions.legacyKeys).toEqual(["group:123@g.us", "group:legacy-room"]); expect(detected.agentDir.hasLegacy).toBe(true); expect(detected.channelPlans.hasLegacy).toBe(true); expect(detected.channelPlans.plans.map((plan) => plan.targetPath)).toEqual([ @@ -128,7 +135,7 @@ describe("state migrations", () => { expect(result.changes).toEqual([ `Migrated latest direct-chat session → agent:worker-1:desk`, `Merged sessions store → ${path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json")}`, - "Canonicalized 1 legacy session key(s)", + "Canonicalized 2 legacy session key(s)", "Moved trace.jsonl → agents/worker-1/sessions", "Moved agent file settings.json → agents/worker-1/agent", `Copied Telegram pairing allowFrom → ${resolveChannelAllowFromPath("telegram", env, "alpha")}`, @@ -144,6 +151,9 @@ describe("state migrations", () => { ) as Record; expect(mergedStore["agent:worker-1:desk"]?.sessionId).toBe("legacy-direct"); expect(mergedStore["agent:worker-1:whatsapp:group:123@g.us"]?.sessionId).toBe("group-session"); + expect(mergedStore["agent:worker-1:unknown:group:legacy-room"]?.sessionId).toBe( + "generic-group-session", + ); await expect( fs.readFile(path.join(stateDir, "agents", "worker-1", "sessions", "trace.jsonl"), "utf8"), diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 225e76d993a..fe9c3408bf3 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -210,6 +210,10 @@ function canonicalizeSessionKeyForAgent(params: { const rest = raw.slice("subagent:".length); return `agent:${agentId}:subagent:${rest}`.toLowerCase(); } + // Channel-owned legacy shapes must win before the generic group/channel + // fallback. WhatsApp shipped channel-qualified group sessions, so + // `group:123@g.us` must canonicalize to `...:whatsapp:group:...`, not the + // generic `...:unknown:group:...` bucket. for (const surface of getLegacySessionSurfaces()) { const canonicalized = surface.canonicalizeLegacySessionKey?.({ key: raw,