import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { Message, Usage } from "@mariozechner/pi-ai"; import { afterAll, describe, expect, it } from "vitest"; import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js"; import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, resolveTrajectoryPointerFilePath } from "./paths.js"; import type { TrajectoryEvent } from "./types.js"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-trajectory-")); let tempDirId = 0; function makeTempDir(): string { const dir = path.join(tempRoot, `case-${tempDirId++}`); fs.mkdirSync(dir, { recursive: true }); return dir; } const emptyUsage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, }, }; function userMessage(content: string): Message { return { role: "user", content, timestamp: 1, }; } function assistantMessage(content: Extract["content"]): Message { return { role: "assistant", content, api: "openai-responses", provider: "openai", model: "gpt-5.4", usage: emptyUsage, stopReason: "stop", timestamp: 2, }; } function toolResultMessage(content: Extract["content"]): Message { return { role: "toolResult", toolCallId: "call_1", toolName: "read", content, isError: false, timestamp: 3, }; } function writeSimpleSessionFile( sessionFile: string, params: { userEntryTimestamp?: string | number } = {}, ): void { const header = { type: "session", version: 3, id: "session-1", timestamp: "2026-04-01T05:46:39.000Z", cwd: path.dirname(sessionFile), }; const userEntry = { type: "message", id: "entry-user", parentId: null, timestamp: params.userEntryTimestamp ?? "2026-04-01T05:46:40.000Z", message: userMessage("hello"), }; const assistantEntry = { type: "message", id: "entry-assistant", parentId: "entry-user", timestamp: "2026-04-01T05:46:41.000Z", message: assistantMessage([{ type: "text", text: "done" }]), }; fs.writeFileSync( sessionFile, `${[header, userEntry, assistantEntry].map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8", ); } function writeToolCallOnlySessionFile(sessionFile: string): void { const header = { type: "session", version: 3, id: "session-1", timestamp: "2026-04-01T05:46:39.000Z", cwd: path.dirname(sessionFile), }; const assistantEntry = { type: "message", id: "entry-assistant", parentId: null, timestamp: "2026-04-01T05:46:41.000Z", message: assistantMessage([ { type: "toolCall", id: "call_1", name: "read", arguments: { filePath: "README.md" }, }, ]), }; fs.writeFileSync( sessionFile, `${[header, assistantEntry].map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8", ); } function writeToolCallSessionFile(sessionFile: string): void { const header = { type: "session", version: 3, id: "session-1", timestamp: "2026-04-01T05:46:39.000Z", cwd: path.dirname(sessionFile), title: "Trajectory Test", }; const entries = [ header, { type: "message", id: "entry-user", parentId: null, timestamp: "2026-04-01T05:46:40.000Z", message: userMessage("hello"), }, { type: "message", id: "entry-tool-call", parentId: "entry-user", timestamp: "2026-04-01T05:46:41.000Z", message: assistantMessage([ { type: "toolCall", id: "call_1", name: "read", arguments: { filePath: path.join(path.dirname(sessionFile), "skills", "weather", "SKILL.md"), }, }, ]), }, { type: "message", id: "entry-tool-result", parentId: "entry-tool-call", timestamp: "2026-04-01T05:46:42.000Z", message: toolResultMessage([{ type: "text", text: "README contents" }]), }, { type: "message", id: "entry-assistant", parentId: "entry-tool-result", timestamp: "2026-04-01T05:46:43.000Z", message: assistantMessage([{ type: "text", text: "done" }]), }, ]; fs.writeFileSync( sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8", ); } afterAll(() => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); describe("exportTrajectoryBundle", () => { it("sanitizes session ids in default export directory names", async () => { const outputDir = resolveDefaultTrajectoryExportDir({ workspaceDir: "/tmp/workspace", sessionId: "../evil/session", now: new Date("2026-04-22T08:00:00.000Z"), }); expect(outputDir).toBe( path.join( "/tmp/workspace", ".openclaw", "trajectory-exports", "openclaw-trajectory-___evil_-2026-04-22T08-00-00", ), ); }); it("refuses to write into an existing output directory", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); fs.mkdirSync(outputDir); await expect( exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }), ).rejects.toThrow(); }); it("does not synthesize prompt files from export-time fallbacks", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, systemPrompt: "fallback prompt", tools: [{ name: "fallback" }], }); expect(bundle.supplementalFiles).not.toContain("prompts.json"); expect(fs.existsSync(path.join(outputDir, "prompts.json"))).toBe(false); expect(fs.existsSync(path.join(outputDir, "system-prompt.txt"))).toBe(false); expect(fs.existsSync(path.join(outputDir, "tools.json"))).toBe(false); }); it("preserves numeric transcript timestamps", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile, { userEntryTimestamp: Date.parse("2026-04-01T05:46:40.000Z"), }); await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }); const exportedEvents = fs .readFileSync(path.join(outputDir, "events.jsonl"), "utf8") .trim() .split(/\r?\n/u) .map((line) => JSON.parse(line) as TrajectoryEvent); expect(exportedEvents.find((event) => event.type === "user.message")?.ts).toBe( "2026-04-01T05:46:40.000Z", ); }); it("rejects oversized runtime trajectory files", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); fs.closeSync(fs.openSync(runtimeFile, "w")); fs.truncateSync(runtimeFile, TRAJECTORY_RUNTIME_FILE_MAX_BYTES + 1); await expect( exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, runtimeFile, }), ).rejects.toThrow(/too large/u); }); it("rejects oversized session transcript files before export", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); fs.closeSync(fs.openSync(sessionFile, "w")); fs.truncateSync(sessionFile, 50 * 1024 * 1024 + 1); await expect( exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }), ).rejects.toThrow(/session file is too large/u); }); it("skips malformed-but-valid runtime json rows before sorting", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); fs.writeFileSync( runtimeFile, `${JSON.stringify({})}\n${JSON.stringify({ traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "session.started", ts: "2026-04-22T08:00:00.000Z", seq: 1, sourceSeq: 1, sessionId: "session-1", })}\n`, "utf8", ); const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }); expect(bundle.manifest.runtimeEventCount).toBe(1); expect(bundle.events.some((event) => event.type === "session.started")).toBe(true); }); it("uses the recorded runtime pointer before current environment overrides", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const recordedRuntimeFile = path.join(tmpDir, "recorded", "session-1.jsonl"); const envRuntimeDir = path.join(tmpDir, "current-env"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); fs.mkdirSync(path.dirname(recordedRuntimeFile), { recursive: true }); fs.mkdirSync(envRuntimeDir); fs.writeFileSync( resolveTrajectoryPointerFilePath(sessionFile), `${JSON.stringify({ traceSchema: "openclaw-trajectory-pointer", schemaVersion: 1, sessionId: "session-1", runtimeFile: recordedRuntimeFile, })}\n`, "utf8", ); fs.writeFileSync( recordedRuntimeFile, `${JSON.stringify({ traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "recorded-runtime", ts: "2026-04-22T08:00:00.000Z", seq: 1, sourceSeq: 1, sessionId: "session-1", })}\n`, "utf8", ); fs.writeFileSync( path.join(envRuntimeDir, "session-1.jsonl"), `${JSON.stringify({ traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "env-runtime", ts: "2026-04-22T08:00:00.000Z", seq: 1, sourceSeq: 1, sessionId: "session-1", })}\n`, "utf8", ); const previous = process.env.OPENCLAW_TRAJECTORY_DIR; process.env.OPENCLAW_TRAJECTORY_DIR = envRuntimeDir; try { const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }); expect(bundle.runtimeFile).toBe(recordedRuntimeFile); expect(bundle.events.some((event) => event.type === "recorded-runtime")).toBe(true); expect(bundle.events.some((event) => event.type === "env-runtime")).toBe(false); } finally { if (previous === undefined) { delete process.env.OPENCLAW_TRAJECTORY_DIR; } else { process.env.OPENCLAW_TRAJECTORY_DIR = previous; } } }); it("ignores runtime pointers that do not look like this session's trajectory file", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outsideFile = path.join(tmpDir, "outside.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); fs.writeFileSync( resolveTrajectoryPointerFilePath(sessionFile), `${JSON.stringify({ traceSchema: "openclaw-trajectory-pointer", schemaVersion: 1, sessionId: "session-1", runtimeFile: outsideFile, })}\n`, "utf8", ); fs.writeFileSync( outsideFile, `${JSON.stringify({ traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "outside-runtime", ts: "2026-04-22T08:00:00.000Z", seq: 1, sourceSeq: 1, sessionId: "session-1", })}\n`, "utf8", ); const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }); expect(bundle.runtimeFile).toBeUndefined(); expect(bundle.events.some((event) => event.type === "outside-runtime")).toBe(false); }); it("does not fall back to runtime pointer targets that are not regular files", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const targetFile = path.join(tmpDir, "outside-target.jsonl"); const symlinkFile = path.join(tmpDir, "recorded", "session-1.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); fs.mkdirSync(path.dirname(symlinkFile), { recursive: true }); fs.writeFileSync( resolveTrajectoryPointerFilePath(sessionFile), `${JSON.stringify({ traceSchema: "openclaw-trajectory-pointer", schemaVersion: 1, sessionId: "session-1", runtimeFile: symlinkFile, })}\n`, "utf8", ); fs.writeFileSync( targetFile, `${JSON.stringify({ traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "symlink-runtime", ts: "2026-04-22T08:00:00.000Z", seq: 1, sourceSeq: 1, sessionId: "session-1", })}\n`, "utf8", ); fs.symlinkSync(targetFile, symlinkFile); const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }); expect(bundle.runtimeFile).toBeUndefined(); expect(bundle.events.some((event) => event.type === "symlink-runtime")).toBe(false); }); it("counts expanded transcript events when enforcing the total event limit", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeToolCallOnlySessionFile(sessionFile); await expect( exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, maxTotalEvents: 1, }), ).rejects.toThrow(/too many events \(2; limit 1\)/u); }); it("skips runtime events for other sessions", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); fs.writeFileSync( runtimeFile, `${JSON.stringify({ traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "other-session", source: "runtime", type: "other-runtime", ts: "2026-04-22T08:00:00.000Z", seq: 1, sourceSeq: 1, sessionId: "other-session", })}\n`, "utf8", ); const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }); expect(bundle.manifest.runtimeEventCount).toBe(0); expect(bundle.events.some((event) => event.type === "other-runtime")).toBe(false); }); it("redacts non-workspace paths in strings that also contain workspace paths", async () => { const tmpDir = makeTempDir(); const homeDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); const outputDir = path.join(tmpDir, "bundle"); const previousHome = process.env.HOME; writeSimpleSessionFile(sessionFile); fs.writeFileSync( runtimeFile, `${JSON.stringify({ traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "mixed-paths", ts: "2026-04-22T08:00:00.000Z", seq: 1, sourceSeq: 1, sessionId: "session-1", data: { value: `workspace=${path.join(tmpDir, "inside.txt")} home=${path.join( homeDir, "secret.txt", )}`, }, })}\n`, "utf8", ); process.env.HOME = homeDir; try { await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, runtimeFile, }); } finally { if (previousHome === undefined) { delete process.env.HOME; } else { process.env.HOME = previousHome; } } const events = fs.readFileSync(path.join(outputDir, "events.jsonl"), "utf8"); expect(events).toContain("$WORKSPACE_DIR"); expect(events).toContain("~"); expect(events).not.toContain(tmpDir); expect(events).not.toContain(homeDir); }); it("exports merged runtime and transcript events plus convenience files", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeToolCallSessionFile(sessionFile); const runtimeEvents: TrajectoryEvent[] = [ { traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "session.started", ts: "2026-04-22T08:00:00.000Z", seq: 1, sourceSeq: 1, sessionId: "session-1", data: { trigger: "user", workspacePath: path.join(tmpDir, "inside.txt"), prefixOnlyPath: `${tmpDir}2/outside.txt`, }, }, { traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "context.compiled", ts: "2026-04-22T08:00:01.000Z", seq: 2, sourceSeq: 2, sessionId: "session-1", data: { systemPrompt: `system prompt for ${path.join(tmpDir, "instructions.md")}`, tools: [ { name: "read", description: `Reads ${path.join(tmpDir, "docs")}`, parameters: { type: "object" }, }, ], }, }, { traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "trace.metadata", ts: "2026-04-22T08:00:01.500Z", seq: 3, sourceSeq: 3, sessionId: "session-1", data: { harness: { type: "openclaw", version: "0.1.0" }, model: { provider: "openai", name: "gpt-5.4" }, skills: { entries: [ { id: "weather", filePath: path.join(tmpDir, "skills", "weather", "SKILL.md"), }, ], }, prompting: { systemPromptReport: { workspaceDir: tmpDir, injectedWorkspaceFiles: [{ path: path.join(tmpDir, "AGENTS.md") }], }, }, }, }, { traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "prompt.submitted", ts: "2026-04-22T08:00:02.000Z", seq: 4, sourceSeq: 4, sessionId: "session-1", data: { prompt: "Please read the weather skill", }, }, { traceSchema: "openclaw-trajectory", schemaVersion: 1, traceId: "session-1", source: "runtime", type: "trace.artifacts", ts: "2026-04-22T08:00:03.000Z", seq: 5, sourceSeq: 5, sessionId: "session-1", data: { finalStatus: "success", assistantTexts: ["done"], finalPromptText: `final prompt from ${path.join(tmpDir, "prompt.txt")}`, itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0, }, }, }, ]; fs.writeFileSync( runtimeFile, `${runtimeEvents.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8", ); const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", sessionKey: "agent:main:session-1", workspaceDir: tmpDir, runtimeFile, systemPrompt: "fallback prompt", tools: [{ name: "fallback" }], }); expect(bundle.manifest.eventCount).toBeGreaterThanOrEqual(5); expect(bundle.manifest.runtimeEventCount).toBe(runtimeEvents.length); expect(fs.existsSync(path.join(outputDir, "manifest.json"))).toBe(true); expect(fs.existsSync(path.join(outputDir, "events.jsonl"))).toBe(true); expect(fs.existsSync(path.join(outputDir, "session.jsonl"))).toBe(false); expect(fs.existsSync(path.join(outputDir, "runtime.jsonl"))).toBe(false); expect(fs.existsSync(path.join(outputDir, "system-prompt.txt"))).toBe(true); expect(fs.existsSync(path.join(outputDir, "tools.json"))).toBe(true); expect(fs.existsSync(path.join(outputDir, "metadata.json"))).toBe(true); expect(fs.existsSync(path.join(outputDir, "artifacts.json"))).toBe(true); expect(fs.existsSync(path.join(outputDir, "prompts.json"))).toBe(true); expect(bundle.supplementalFiles).toEqual(["metadata.json", "artifacts.json", "prompts.json"]); const exportedEvents = fs .readFileSync(path.join(outputDir, "events.jsonl"), "utf8") .trim() .split(/\r?\n/u) .map((line) => JSON.parse(line) as TrajectoryEvent); expect(exportedEvents.some((event) => event.type === "tool.call")).toBe(true); expect(exportedEvents.some((event) => event.type === "tool.result")).toBe(true); expect(exportedEvents.some((event) => event.type === "context.compiled")).toBe(true); expect(JSON.stringify(exportedEvents)).toContain("$WORKSPACE_DIR/inside.txt"); expect(JSON.stringify(exportedEvents)).not.toContain("$WORKSPACE_DIR2"); const manifest = JSON.parse(fs.readFileSync(path.join(outputDir, "manifest.json"), "utf8")) as { contents?: Array<{ path: string; mediaType: string; bytes: number }>; sourceFiles?: { session?: string; runtime?: string }; workspaceDir?: string; }; expect(manifest.workspaceDir).toBe("$WORKSPACE_DIR"); expect(manifest.sourceFiles?.session).toBe("$WORKSPACE_DIR/session.jsonl"); expect(manifest.sourceFiles?.runtime).toBe("$WORKSPACE_DIR/session.trajectory.jsonl"); expect(manifest.contents?.map((entry) => entry.path).toSorted()).toEqual([ "artifacts.json", "events.jsonl", "metadata.json", "prompts.json", "session-branch.json", "system-prompt.txt", "tools.json", ]); expect(manifest.contents?.every((entry) => entry.bytes > 0)).toBe(true); const metadata = JSON.parse(fs.readFileSync(path.join(outputDir, "metadata.json"), "utf8")) as { skills?: { entries?: Array<{ id?: string; invoked?: boolean }> }; }; expect(metadata.skills?.entries?.[0]).toMatchObject({ id: "weather", invoked: true, }); const prompts = fs.readFileSync(path.join(outputDir, "prompts.json"), "utf8"); const artifacts = fs.readFileSync(path.join(outputDir, "artifacts.json"), "utf8"); const systemPrompt = fs.readFileSync(path.join(outputDir, "system-prompt.txt"), "utf8"); const tools = fs.readFileSync(path.join(outputDir, "tools.json"), "utf8"); expect(prompts).toContain("$WORKSPACE_DIR/AGENTS.md"); expect(artifacts).toContain("$WORKSPACE_DIR/prompt.txt"); expect(systemPrompt).toContain("$WORKSPACE_DIR/instructions.md"); expect(tools).toContain("$WORKSPACE_DIR/docs"); expect(`${prompts}\n${artifacts}\n${systemPrompt}\n${tools}`).not.toContain(tmpDir); }); });