mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 21:34:46 +00:00
fix(export): report malformed transcript rows (#82553)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user