diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab6db3f4ff..268d5039f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - Trajectory export: report incomplete transcript parent chains and stop cyclic branch walks so malformed imports cannot hang `/export-trajectory`. - Session replay: skip malformed user/assistant-shaped transcript rows during silent session resets instead of copying invalid entries into the fresh transcript. - Backup verify: report malformed archive manifests with a stable error instead of leaking raw JSON parser details. +- Session export: report skipped malformed transcript JSONL rows instead of silently omitting them from exported HTML archives. - Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling. - Providers/images: reject malformed successful OpenAI-compatible, OpenAI, Google, fal, and OpenRouter image responses with provider-owned errors instead of raw shape failures, silent invalid base64 skips, or empty image results. - Providers/videos: reject malformed successful xAI, OpenRouter, and fal video create, poll, and result responses with provider-owned errors instead of raw parser failures or long bogus polling. diff --git a/src/auto-reply/reply/commands-export-session.test.ts b/src/auto-reply/reply/commands-export-session.test.ts index 35294f16a0a..608f141f9f5 100644 --- a/src/auto-reply/reply/commands-export-session.test.ts +++ b/src/auto-reply/reply/commands-export-session.test.ts @@ -21,6 +21,7 @@ const hoisted = await vi.hoisted(async () => { accessMock: vi.fn(async (_filePath: string) => undefined), pathExistsMock: vi.fn(async (_filePath: string) => true), exportHtmlTemplateContents: new Map(), + sessionTranscriptContent: "", }; }); @@ -73,7 +74,7 @@ vi.mock("node:fs/promises", async () => { writeFile: hoisted.writeFileMock, readFile: vi.fn(async (filePath: string, encoding?: BufferEncoding) => { if (filePath === "/tmp/target-store/session.jsonl") { - return ""; + return hoisted.sessionTranscriptContent; } for (const [suffix, contents] of hoisted.exportHtmlTemplateContents) { if (filePath.endsWith(suffix)) { @@ -182,6 +183,7 @@ describe("buildExportSessionReply", () => { hoisted.accessMock.mockResolvedValue(undefined); hoisted.pathExistsMock.mockResolvedValue(true); hoisted.exportHtmlTemplateContents.clear(); + hoisted.sessionTranscriptContent = ""; }); it("resolves store and transcript paths from the target session agent", async () => { @@ -313,4 +315,40 @@ describe("buildExportSessionReply", () => { expect(html).toContain("const markedMarker = '$&$1';"); expect(html).toContain("const highlightMarker = '$&$1';"); }); + + it("reports malformed transcript rows without leaking parser details", async () => { + hoisted.sessionTranscriptContent = [ + JSON.stringify({ type: "session", version: 3, id: "session-1" }), + '{"type":"message",', + JSON.stringify({ + type: "message", + id: "entry-1", + timestamp: "2026-05-16T00:00:00.000Z", + message: { role: "user", content: "valid user" }, + }), + JSON.stringify({ + type: "message", + id: "entry-2", + timestamp: "2026-05-16T00:00:01.000Z", + message: { content: "missing role" }, + }), + JSON.stringify({ + type: "message", + id: "entry-3", + timestamp: "2026-05-16T00:00:02.000Z", + message: { role: "assistant", content: "valid assistant" }, + }), + ].join("\n"); + + const reply = await buildExportSessionReply(makeParams()); + + expect(reply.text).toContain("📊 Entries: 2"); + expect(reply.text).toContain( + "⚠️ Skipped 1 malformed transcript row that was not valid JSON. rows 2", + ); + expect(reply.text).toContain( + "⚠️ Skipped 1 malformed transcript row that was not a session entry. rows 4", + ); + expect(reply.text).not.toMatch(/Unexpected|SyntaxError|position/i); + }); }); diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index b9e97592a2c..96f1b6e04a2 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { migrateSessionEntries, - parseSessionEntries, + type FileEntry as PiSessionFileEntry, type SessionEntry as PiSessionEntry, type SessionHeader, } from "@earendil-works/pi-coding-agent"; @@ -28,6 +28,17 @@ interface SessionData { tools?: Array<{ name: string; description?: string; parameters?: unknown }>; } +type SessionExportJsonlWarning = { + code: "invalid-session-json" | "invalid-session-row"; + row: number; +}; + +type SessionExportWarningSummary = { + code: SessionExportJsonlWarning["code"]; + count: number; + rows: number[]; +}; + async function loadTemplate(fileName: string): Promise { return await fsp.readFile(path.join(EXPORT_HTML_DIR, fileName), "utf-8"); } @@ -144,20 +155,104 @@ async function writeNewDefaultExportFile(filePath: string, html: string): Promis } throw new Error(`Could not find an unused export filename near ${filePath}`); } + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isSessionFileEntry(value: unknown): value is PiSessionFileEntry { + if (!isRecord(value) || typeof value.type !== "string") { + return false; + } + if (value.type !== "message") { + return true; + } + const message = value.message; + return isRecord(message) && typeof message.role === "string"; +} + +function parseSessionEntriesWithWarnings(content: string): { + entries: PiSessionFileEntry[]; + warnings: SessionExportJsonlWarning[]; +} { + const entries: PiSessionFileEntry[] = []; + const warnings: SessionExportJsonlWarning[] = []; + const rows = content.split(/\r?\n/u); + for (const [index, rawLine] of rows.entries()) { + const line = rawLine.trim(); + if (!line) { + continue; + } + try { + const parsed = JSON.parse(line) as unknown; + if (!isSessionFileEntry(parsed)) { + warnings.push({ code: "invalid-session-row", row: index + 1 }); + continue; + } + entries.push(parsed); + } catch { + warnings.push({ code: "invalid-session-json", row: index + 1 }); + } + } + return { entries, warnings }; +} + +function summarizeSessionExportWarnings( + warnings: SessionExportJsonlWarning[], +): SessionExportWarningSummary[] { + const summaries = new Map(); + for (const warning of warnings) { + const summary = summaries.get(warning.code); + if (summary) { + summary.count += 1; + if (summary.rows.length < 20) { + summary.rows.push(warning.row); + } + continue; + } + summaries.set(warning.code, { + code: warning.code, + count: 1, + rows: [warning.row], + }); + } + return [...summaries.values()]; +} + +function formatSkippedRows(count: number): string { + return `${count.toLocaleString()} malformed transcript ${count === 1 ? "row" : "rows"}`; +} + +function formatSessionExportWarning(summary: SessionExportWarningSummary): string { + const rows = summary.rows.length > 0 ? ` rows ${summary.rows.join(", ")}` : ""; + const verb = summary.count === 1 ? "was" : "were"; + switch (summary.code) { + case "invalid-session-json": + return `⚠️ Skipped ${formatSkippedRows(summary.count)} that ${verb} not valid JSON.${rows}`; + case "invalid-session-row": + return summary.count === 1 + ? `⚠️ Skipped ${formatSkippedRows(summary.count)} that was not a session entry.${rows}` + : `⚠️ Skipped ${formatSkippedRows(summary.count)} that were not session entries.${rows}`; + } + const unreachable: never = summary.code; + return unreachable; +} + async function readSessionDataFromTranscript(sessionFile: string): Promise<{ header: SessionHeader | null; entries: PiSessionEntry[]; leafId: string | null; + warnings: SessionExportWarningSummary[]; }> { const raw = await fsp.readFile(sessionFile, "utf-8"); - const fileEntries = parseSessionEntries(raw); + const { entries: fileEntries, warnings } = parseSessionEntriesWithWarnings(raw); migrateSessionEntries(fileEntries); const header = fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null; const entries = fileEntries.filter((entry): entry is PiSessionEntry => entry.type !== "session"); const lastEntry = entries.at(-1); const leafId = typeof lastEntry?.id === "string" ? lastEntry.id : null; - return { header, entries, leafId }; + return { header, entries, leafId, warnings: summarizeSessionExportWarnings(warnings) }; } export async function buildExportSessionReply(params: HandleCommandsParams): Promise { @@ -179,7 +274,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro } // 2. Load session entries - const { entries, header, leafId } = await readSessionDataFromTranscript(sessionFile); + const { entries, header, leafId, warnings } = await readSessionDataFromTranscript(sessionFile); // 3. Build full system prompt const { systemPrompt, tools } = await resolveCommandsSystemPromptBundle({ @@ -234,6 +329,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro "", `📄 File: ${displayPath}`, `📊 Entries: ${entries.length}`, + ...warnings.map(formatSessionExportWarning), `🧠 System prompt: ${systemPrompt.length.toLocaleString()} chars`, `🔧 Tools: ${tools.length}`, ].join("\n"),