Files
openclaw/src/trajectory/runtime.test.ts
2026-05-08 16:59:17 +01:00

222 lines
7.1 KiB
TypeScript

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<ReturnType<typeof createTrajectoryRuntimeRecorder>>;
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<typeof createTrajectoryRuntimeRecorder>,
): 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();
});
});