import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { TRAJECTORY_RUNTIME_EVENT_MAX_BYTES, createTrajectoryRuntimeRecorder, resolveTrajectoryPointerOpenFlags, resolveTrajectoryPointerFilePath, resolveTrajectoryFilePath, toTrajectoryToolDefinitions, } from "./runtime.js"; type TrajectoryRuntimeRecorder = NonNullable>; const tempDirs: string[] = []; function makeTempDir(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-trajectory-runtime-")); tempDirs.push(dir); return dir; } afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); function expectTrajectoryRuntimeRecorder( recorder: ReturnType, ): TrajectoryRuntimeRecorder { expect(recorder).toEqual(expect.objectContaining({ recordEvent: expect.any(Function) })); if (recorder === null) { throw new Error("Expected trajectory runtime recorder"); } return recorder; } describe("trajectory runtime", () => { it("resolves a session-adjacent trajectory file by default", () => { expect( resolveTrajectoryFilePath({ sessionFile: "/tmp/session.jsonl", sessionId: "session-1", }), ).toBe("/tmp/session.trajectory.jsonl"); }); it("sanitizes session ids when resolving an override directory", () => { expect( resolveTrajectoryFilePath({ env: { OPENCLAW_TRAJECTORY_DIR: "/tmp/traces" }, sessionId: "../evil/session", }), ).toBe("/tmp/traces/___evil_session.jsonl"); }); it("records sanitized runtime events by default", () => { const writes: string[] = []; const recorder = createTrajectoryRuntimeRecorder({ sessionId: "session-1", sessionKey: "agent:main:session-1", sessionFile: "/tmp/session.jsonl", provider: "openai", modelId: "gpt-5.4", modelApi: "responses", workspaceDir: "/tmp/workspace", writer: { filePath: "/tmp/session.trajectory.jsonl", write: (line) => { writes.push(line); }, flush: async () => undefined, }, }); const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); runtimeRecorder.recordEvent("context.compiled", { systemPrompt: "system prompt", headers: [{ name: "Authorization", value: "Bearer sk-test-secret-token" }], command: "curl -H 'Authorization: Bearer sk-other-secret-token'", tools: toTrajectoryToolDefinitions([ { name: "z-tool", parameters: { z: 1 } }, { name: "a-tool", description: "alpha", parameters: { a: 1 } }, { name: " ", description: "ignored" }, ]), }); expect(writes).toHaveLength(1); const parsed = JSON.parse(writes[0]); expect(parsed.type).toBe("context.compiled"); expect(parsed.source).toBe("runtime"); expect(parsed.sessionId).toBe("session-1"); expect(parsed.data.tools).toEqual([ { name: "a-tool", description: "alpha", parameters: { a: 1 } }, { name: "z-tool", parameters: { z: 1 } }, ]); expect(JSON.stringify(parsed.data)).not.toContain("sk-test-secret-token"); expect(JSON.stringify(parsed.data)).not.toContain("sk-other-secret-token"); }); it("bounds large runtime event fields before serialization", () => { const writes: string[] = []; const recorder = createTrajectoryRuntimeRecorder({ sessionId: "session-1", sessionFile: "/tmp/session.jsonl", writer: { filePath: "/tmp/session.trajectory.jsonl", write: (line) => { writes.push(line); }, flush: async () => undefined, }, }); const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); runtimeRecorder.recordEvent("context.compiled", { prompt: "x".repeat(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1), }); expect(writes).toHaveLength(1); const parsed = JSON.parse(writes[0]); expect(parsed.data.prompt).toMatchObject({ truncated: true, reason: "trajectory-field-size-limit", }); expect(Buffer.byteLength(writes[0], "utf8")).toBeLessThanOrEqual( TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1, ); }); it("stops runtime capture at the file budget and records a truncation event", async () => { const writes: string[] = []; const recorder = createTrajectoryRuntimeRecorder({ sessionId: "session-1", sessionFile: "/tmp/session.jsonl", maxRuntimeFileBytes: 900, writer: { filePath: "/tmp/session.trajectory.jsonl", write: (line) => { writes.push(line); }, flush: async () => undefined, }, }); const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); runtimeRecorder.recordEvent("context.compiled", { prompt: "x".repeat(180), }); runtimeRecorder.recordEvent("prompt.submitted", { prompt: "y".repeat(180), }); runtimeRecorder.recordEvent("model.completed", { get prompt() { throw new Error("stopped recorder should not read dropped payloads"); }, }); await runtimeRecorder.flush(); const parsed = writes.map((line) => JSON.parse(line)); expect(parsed.map((event) => event.type)).toContain("trace.truncated"); const truncated = parsed.find((event) => event.type === "trace.truncated"); expect(truncated?.data).toMatchObject({ reason: "trajectory-runtime-file-size-limit", limitBytes: 900, }); expect(truncated?.data.droppedEvents).toBeGreaterThan(0); }); it("writes a session-adjacent pointer when using an override directory", () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const trajectoryDir = path.join(tmpDir, "traces"); const recorder = createTrajectoryRuntimeRecorder({ env: { OPENCLAW_TRAJECTORY_DIR: trajectoryDir }, sessionId: "session-1", sessionFile, writer: { filePath: path.join(trajectoryDir, "session-1.jsonl"), write: () => undefined, flush: async () => undefined, }, }); expectTrajectoryRuntimeRecorder(recorder); const pointer = JSON.parse( fs.readFileSync(resolveTrajectoryPointerFilePath(sessionFile), "utf8"), ) as { runtimeFile?: string }; expect(pointer.runtimeFile).toBe(path.join(trajectoryDir, "session-1.jsonl")); }); it("keeps pointer write flags usable when O_NOFOLLOW is unavailable", () => { expect( resolveTrajectoryPointerOpenFlags({ O_CREAT: 0x01, O_TRUNC: 0x02, O_WRONLY: 0x04, }), ).toBe(0x07); }); it("does not record runtime events when explicitly disabled", () => { const recorder = createTrajectoryRuntimeRecorder({ env: { OPENCLAW_TRAJECTORY: "0", }, sessionId: "session-1", sessionKey: "agent:main:session-1", sessionFile: "/tmp/session.jsonl", writer: { filePath: "/tmp/session.trajectory.jsonl", write: () => undefined, flush: async () => undefined, }, }); expect(recorder).toBeNull(); }); });