fix(export): report malformed transcript rows (#82553)

This commit is contained in:
Vincent Koc
2026-05-16 20:03:28 +08:00
committed by GitHub
parent d32b2a4771
commit 192caba631
3 changed files with 140 additions and 5 deletions

View File

@@ -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.

View File

@@ -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<string, string>(),
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);
});
});

View File

@@ -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<string> {
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<string, unknown> {
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<SessionExportJsonlWarning["code"], SessionExportWarningSummary>();
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<ReplyPayload> {
@@ -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"),