diff --git a/src/agents/sessions/session-manager.ts b/src/agents/sessions/session-manager.ts index 8e06fe8d44d..f6d56732846 100644 --- a/src/agents/sessions/session-manager.ts +++ b/src/agents/sessions/session-manager.ts @@ -1015,7 +1015,13 @@ function parseJsonlEntries(content: string): FileEntry[] { return entries; } -function normalizeLoadedFileEntry(entry: FileEntry): FileEntry { +/** + * Repairs legacy JSONL message shapes (string assistant/toolResult content) into + * canonical block arrays. Every legacy-JSONL ingress point — file-era transcript + * reads and the doctor SQLite import — must apply this so the SQLite store only + * ever holds canonical rows; the SQLite read path does no repair. + */ +export function normalizeLoadedFileEntry(entry: FileEntry): FileEntry { if (!isJsonRecord(entry) || entry.type !== "message" || !isJsonRecord(entry.message)) { return entry; } diff --git a/src/commands/doctor-session-sqlite-readers.ts b/src/commands/doctor-session-sqlite-readers.ts index 367809ecf2c..b0dd04df0d0 100644 --- a/src/commands/doctor-session-sqlite-readers.ts +++ b/src/commands/doctor-session-sqlite-readers.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { TextDecoder } from "node:util"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeLoadedFileEntry, type FileEntry } from "../agents/sessions/session-manager.js"; import type { TranscriptEvent } from "../config/sessions/session-accessor.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import type { SessionStoreTarget } from "../config/sessions/targets.js"; @@ -77,7 +78,9 @@ export function createTranscriptEventReader( for (const line of iterateJsonlLinesSync(transcriptPath)) { const parsed = parseJsonlLine(line); if (parsed) { - append(parsed as TranscriptEvent); + // Import is the migration boundary: repair legacy JSONL message shapes + // here because the SQLite runtime read path assumes canonical rows. + append(normalizeLoadedFileEntry(parsed as FileEntry) as TranscriptEvent); } } }; @@ -91,7 +94,7 @@ export function createTranscriptEventPrefixReader( for (const line of iterateJsonlLinesSync(transcriptPath)) { const parsed = parseJsonlLine(line); if (parsed) { - append(parsed as TranscriptEvent); + append(normalizeLoadedFileEntry(parsed as FileEntry) as TranscriptEvent); } } } catch { diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index 4a8a480a5a4..90fee4cf4f4 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -117,6 +117,44 @@ describe("runDoctorSessionSqlite", () => { } }); + it("repairs legacy message and route shapes at the import boundary", async () => { + const store = createLegacyStore({ + entryOverrides: { + route: "stale-custom-slot", + deliveryContext: { channel: "telegram", to: "123" }, + }, + transcriptLines: [ + '{"type":"session","sessionId":"session-1"}', + '{"type":"message","id":"m1","parentId":null,"message":{"role":"assistant","content":"legacy string"}}', + ], + }); + + const report = await runDoctorSessionSqlite({ + env: store.env, + mode: "import", + store: store.storePath, + }); + + expect(report.totals).toMatchObject({ importedEntries: 1, issues: 0 }); + const imported = loadExactSqliteSessionEntry({ + agentId: "main", + sessionKey: "agent:main:main", + storePath: store.storePath, + }); + // The SQLite runtime does no read repair, so import must store canonical shapes. + expect(typeof imported?.entry.route).not.toBe("string"); + const events = loadSqliteTranscriptEventsSync({ + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:main", + storePath: store.storePath, + }); + const message = events.find((event) => (event as { type?: string }).type === "message") as { + message?: { content?: unknown }; + }; + expect(message?.message?.content).toEqual([{ type: "text", text: "legacy string" }]); + }); + it("imports and validates legacy sessions idempotently", async () => { const store = createLegacyStore(); @@ -1118,7 +1156,12 @@ describe("runDoctorSessionSqlite", () => { }); function createLegacyStore( - params: { agentDirName?: string; customStore?: boolean; transcriptLines?: string[] } = {}, + params: { + agentDirName?: string; + customStore?: boolean; + entryOverrides?: Record; + transcriptLines?: string[]; + } = {}, ): TestStore { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-session-sqlite-")); const stateDir = path.join(tempDir, "state"); @@ -1143,6 +1186,7 @@ function createLegacyStore( sessionId: "session-1", sessionStartedAt: 1000, updatedAt: 2000, + ...params.entryOverrides, }, }, null, diff --git a/src/commands/doctor-session-sqlite.ts b/src/commands/doctor-session-sqlite.ts index 3e8f4aa15b1..40fc80b2131 100644 --- a/src/commands/doctor-session-sqlite.ts +++ b/src/commands/doctor-session-sqlite.ts @@ -11,6 +11,7 @@ import { import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { normalizeStoreSessionKey } from "../config/sessions/store-entry.js"; +import { normalizeSessionEntryDelivery } from "../config/sessions/store-load.js"; import { resolveAgentSessionStoreTargetsSync, resolveAllAgentSessionStoreTargetsSync, @@ -323,7 +324,9 @@ function readLegacySessionRecords( continue; } records.push({ - entry: value, + // Import is the migration boundary: repair legacy delivery/route shapes + // here because the SQLite runtime read path assumes canonical entries. + entry: normalizeSessionEntryDelivery(value), sessionKey, transcriptPath: resolveLegacyTranscriptPath(target, value), }); diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index 0eda72008fa..2cd43e0e0b4 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -296,7 +296,12 @@ function sameDeliveryChannelRoute( ); } -function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry { +/** + * Rebuilds malformed/legacy delivery `route` state from the entry's delivery + * fields. Runs on file-era store loads and on the doctor SQLite import so the + * SQLite store only holds canonical delivery shapes; SQLite reads do no repair. + */ +export function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry { const entryRoute = normalizeDeliveryChannelRoute(entry.route); const normalized = normalizeSessionDeliveryFields({ route: entryRoute,