diff --git a/src/auto-reply/reply/commands-export-session.test.ts b/src/auto-reply/reply/commands-export-session.test.ts index 09f132b114f..6e7a162e7b5 100644 --- a/src/auto-reply/reply/commands-export-session.test.ts +++ b/src/auto-reply/reply/commands-export-session.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { HandleCommandsParams } from "./commands-types.js"; const hoisted = await vi.hoisted(async () => { @@ -119,6 +120,10 @@ function makeParams(): HandleCommandsParams { } describe("buildExportSessionReply", () => { + afterEach(() => { + vi.useRealTimers(); + }); + beforeEach(() => { vi.clearAllMocks(); hoisted.resolveDefaultSessionStorePathMock.mockReturnValue("/tmp/target-store/sessions.json"); @@ -231,6 +236,32 @@ describe("buildExportSessionReply", () => { expect(html).toContain('const base64 = document.getElementById("session-data").textContent;'); }); + it("suffixes colliding default export filenames instead of overwriting", async () => { + const { buildExportSessionReply } = await import("./commands-export-session.js"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-05T10:11:12.345Z")); + const collision = Object.assign(new Error("exists"), { code: "EEXIST" }); + hoisted.writeFileMock.mockRejectedValueOnce(collision).mockResolvedValueOnce(undefined); + + const reply = await buildExportSessionReply(makeParams()); + + const expectedBase = path.join( + "/tmp/workspace", + "openclaw-session-session--2026-05-05T10-11-12.html", + ); + const expectedSuffix = path.join( + "/tmp/workspace", + "openclaw-session-session--2026-05-05T10-11-12-2.html", + ); + expect(hoisted.writeFileMock.mock.calls[0]?.[0]).toBe(expectedBase); + expect(hoisted.writeFileMock.mock.calls[0]?.[2]).toMatchObject({ + encoding: "utf-8", + flag: "wx", + }); + expect(hoisted.writeFileMock.mock.calls[1]?.[0]).toBe(expectedSuffix); + expect(reply.text).toContain("📄 File: openclaw-session-session--2026-05-05T10-11-12-2.html"); + }); + it("preserves replacement text with dollar sequences", async () => { const { buildExportSessionReply } = await import("./commands-export-session.js"); hoisted.exportHtmlTemplateContents.set( diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index 6f0e830e2e6..d372aff20b0 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -131,6 +131,28 @@ async function fileExists(pathName: string): Promise { } } +function addCollisionSuffix(filePath: string, suffix: number): string { + const ext = path.extname(filePath); + const baseName = path.basename(filePath, ext); + return path.join(path.dirname(filePath), `${baseName}-${suffix}${ext}`); +} + +async function writeNewDefaultExportFile(filePath: string, html: string): Promise { + for (let suffix = 1; suffix <= 100; suffix++) { + const candidate = suffix === 1 ? filePath : addCollisionSuffix(filePath, suffix); + try { + await fsp.writeFile(candidate, html, { encoding: "utf-8", flag: "wx" }); + return candidate; + } catch (error) { + if (typeof error === "object" && error && "code" in error && error.code === "EEXIST") { + continue; + } + throw error; + } + } + throw new Error(`Could not find an unused export filename near ${filePath}`); +} + async function readSessionDataFromTranscript(sessionFile: string): Promise<{ header: SessionHeader | null; entries: PiSessionEntry[]; @@ -193,7 +215,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro // 6. Determine output path const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const defaultFileName = `openclaw-session-${entry.sessionId.slice(0, 8)}-${timestamp}.html`; - const outputPath = args.outputPath + let outputPath = args.outputPath ? path.resolve( args.outputPath.startsWith("~") ? args.outputPath.replace("~", process.env.HOME ?? "") @@ -206,7 +228,11 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro await fsp.mkdir(outputDir, { recursive: true }); // 7. Write file - await fsp.writeFile(outputPath, html, "utf-8"); + if (args.outputPath) { + await fsp.writeFile(outputPath, html, "utf-8"); + } else { + outputPath = await writeNewDefaultExportFile(outputPath, html); + } const relativePath = path.relative(params.workspaceDir, outputPath); const displayPath = relativePath.startsWith("..") ? outputPath : relativePath;