diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts index ffc84218f49..190b926ad9b 100644 --- a/src/config/sessions.cache.test.ts +++ b/src/config/sessions.cache.test.ts @@ -107,6 +107,40 @@ describe("Session Store Cache", () => { expect(loaded2["session:1"].skillsSnapshot?.skills?.[0]?.name).toBe("alpha"); }); + it("does not cache pre-migration or pre-normalization disk JSON", () => { + fs.writeFileSync( + storePath, + JSON.stringify({ + "session:1": { + sessionId: "id-1", + updatedAt: Date.now(), + provider: "telegram", + room: "room-1", + modelProvider: " openai ", + model: " gpt-5.4 ", + }, + }), + ); + + const loaded1 = loadSessionStore(storePath); + const entry1 = loaded1["session:1"] as SessionEntry & { provider?: string; room?: string }; + expect(entry1.channel).toBe("telegram"); + expect(entry1.groupChannel).toBe("room-1"); + expect(entry1.provider).toBeUndefined(); + expect(entry1.room).toBeUndefined(); + expect(entry1.modelProvider).toBe("openai"); + expect(entry1.model).toBe("gpt-5.4"); + + const loaded2 = loadSessionStore(storePath); + const entry2 = loaded2["session:1"] as SessionEntry & { provider?: string; room?: string }; + expect(entry2.channel).toBe("telegram"); + expect(entry2.groupChannel).toBe("room-1"); + expect(entry2.provider).toBeUndefined(); + expect(entry2.room).toBeUndefined(); + expect(entry2.modelProvider).toBe("openai"); + expect(entry2.model).toBe("gpt-5.4"); + }); + it("isolates cached session stores without structuredClone", async () => { const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone"); const testStore = createSingleSessionStore( diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index 40aa884fd8e..e13b335271e 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -64,7 +64,8 @@ function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry { }; } -export function normalizeSessionStore(store: Record): void { +export function normalizeSessionStore(store: Record): boolean { + let changed = false; for (const [key, entry] of Object.entries(store)) { if (!entry) { continue; @@ -72,8 +73,10 @@ export function normalizeSessionStore(store: Record): void const normalized = normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry)); if (normalized !== entry) { store[key] = normalized; + changed = true; } } + return changed; } export function loadSessionStore( @@ -123,14 +126,11 @@ export function loadSessionStore( } } - if (serializedFromDisk !== undefined) { - setSerializedSessionStore(storePath, serializedFromDisk); - } else { - setSerializedSessionStore(storePath, undefined); + const migrated = applySessionStoreMigrations(store); + const normalized = normalizeSessionStore(store); + if (migrated || normalized) { + serializedFromDisk = undefined; } - - applySessionStoreMigrations(store); - normalizeSessionStore(store); const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfig(); const beforeCount = Object.keys(store).length; if (maintenance.mode === "enforce" && beforeCount > maintenance.maxEntries) { @@ -145,7 +145,6 @@ export function loadSessionStore( const afterCount = Object.keys(store).length; if (pruned > 0 || capped > 0) { serializedFromDisk = undefined; - setSerializedSessionStore(storePath, undefined); log.info("applied load-time maintenance to oversized session store", { storePath, before: beforeCount, @@ -157,6 +156,8 @@ export function loadSessionStore( } } + setSerializedSessionStore(storePath, serializedFromDisk); + if (!opts.skipCache && isSessionStoreCacheEnabled()) { writeSessionStoreCache({ storePath, diff --git a/src/config/sessions/store-migrations.ts b/src/config/sessions/store-migrations.ts index 0d161f734d6..945ea603e52 100644 --- a/src/config/sessions/store-migrations.ts +++ b/src/config/sessions/store-migrations.ts @@ -1,6 +1,7 @@ import type { SessionEntry } from "./types.js"; -export function applySessionStoreMigrations(store: Record): void { +export function applySessionStoreMigrations(store: Record): boolean { + let changed = false; // Best-effort migration: message provider → channel naming. for (const entry of Object.values(store)) { if (!entry || typeof entry !== "object") { @@ -10,18 +11,23 @@ export function applySessionStoreMigrations(store: Record) if (typeof rec.channel !== "string" && typeof rec.provider === "string") { rec.channel = rec.provider; delete rec.provider; + changed = true; } if (typeof rec.lastChannel !== "string" && typeof rec.lastProvider === "string") { rec.lastChannel = rec.lastProvider; delete rec.lastProvider; + changed = true; } // Best-effort migration: legacy `room` field → `groupChannel` (keep value, prune old key). if (typeof rec.groupChannel !== "string" && typeof rec.room === "string") { rec.groupChannel = rec.room; delete rec.room; + changed = true; } else if ("room" in rec) { delete rec.room; + changed = true; } } + return changed; }