mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 06:22:55 +00:00
* refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types
1002 lines
30 KiB
TypeScript
1002 lines
30 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { Message, Usage } from "openclaw/plugin-sdk/llm";
|
|
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<Message, { role: "assistant" }>["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<Message, { role: "toolResult" }>["content"]): Message {
|
|
return {
|
|
role: "toolResult",
|
|
toolCallId: "call_1",
|
|
toolName: "read",
|
|
content,
|
|
isError: false,
|
|
timestamp: 3,
|
|
};
|
|
}
|
|
|
|
function eventTypes(events: readonly Pick<TrajectoryEvent, "type">[]): string[] {
|
|
return events.map((event) => event.type);
|
|
}
|
|
|
|
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", () => {
|
|
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);
|
|
|
|
try {
|
|
await exportTrajectoryBundle({
|
|
outputDir,
|
|
sessionFile,
|
|
sessionId: "session-1",
|
|
workspaceDir: tmpDir,
|
|
});
|
|
throw new Error("expected trajectory export to reject an existing output directory");
|
|
} catch (error) {
|
|
expect((error as NodeJS.ErrnoException).code).toBe("EEXIST");
|
|
}
|
|
});
|
|
|
|
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({}),
|
|
"",
|
|
JSON.stringify({
|
|
traceSchema: "openclaw-trajectory",
|
|
schemaVersion: 1,
|
|
traceId: "session-1",
|
|
source: "runtime",
|
|
type: "bad-data",
|
|
ts: "2026-04-22T08:00:00.000Z",
|
|
seq: 1,
|
|
sourceSeq: 1,
|
|
sessionId: "session-1",
|
|
data: [],
|
|
}),
|
|
'{"traceSchema":',
|
|
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",
|
|
}),
|
|
].join("\n") + "\n",
|
|
"utf8",
|
|
);
|
|
|
|
const bundle = await exportTrajectoryBundle({
|
|
outputDir,
|
|
sessionFile,
|
|
sessionId: "session-1",
|
|
workspaceDir: tmpDir,
|
|
});
|
|
|
|
expect(bundle.manifest.runtimeEventCount).toBe(1);
|
|
expect(eventTypes(bundle.events)).toContain("session.started");
|
|
expect(bundle.manifest.warnings).toEqual([
|
|
{
|
|
source: "runtime",
|
|
code: "invalid-runtime-event",
|
|
count: 2,
|
|
rows: [2, 4],
|
|
message: "Skipped a runtime trajectory JSONL row that does not match the session schema.",
|
|
},
|
|
{
|
|
source: "runtime",
|
|
code: "invalid-runtime-json",
|
|
count: 1,
|
|
rows: [5],
|
|
message: "Skipped a runtime trajectory JSONL row that is not valid JSON.",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("skips and reports malformed session jsonl rows without poisoning transcript export", async () => {
|
|
const tmpDir = makeTempDir();
|
|
const sessionFile = path.join(tmpDir, "session.jsonl");
|
|
const outputDir = path.join(tmpDir, "bundle");
|
|
const header = {
|
|
type: "session",
|
|
version: 3,
|
|
id: "session-1",
|
|
timestamp: "2026-04-01T05:46:39.000Z",
|
|
cwd: tmpDir,
|
|
};
|
|
const userEntry = {
|
|
type: "message",
|
|
id: "entry-user",
|
|
parentId: null,
|
|
timestamp: "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,
|
|
[
|
|
JSON.stringify(header),
|
|
"null",
|
|
'{"type":',
|
|
JSON.stringify({
|
|
type: "message",
|
|
id: "entry-corrupt",
|
|
parentId: null,
|
|
timestamp: "2026-04-01T05:46:39.500Z",
|
|
}),
|
|
JSON.stringify(userEntry),
|
|
JSON.stringify(assistantEntry),
|
|
].join("\n") + "\n",
|
|
"utf8",
|
|
);
|
|
|
|
const bundle = await exportTrajectoryBundle({
|
|
outputDir,
|
|
sessionFile,
|
|
sessionId: "session-1",
|
|
workspaceDir: tmpDir,
|
|
});
|
|
|
|
expect(bundle.manifest.transcriptEventCount).toBe(2);
|
|
expect(eventTypes(bundle.events)).toEqual(["user.message", "assistant.message"]);
|
|
expect(bundle.manifest.warnings).toEqual([
|
|
{
|
|
source: "session",
|
|
code: "invalid-session-row",
|
|
count: 2,
|
|
rows: [2, 4],
|
|
message: "Skipped a session JSONL row that is not a session entry object.",
|
|
},
|
|
{
|
|
source: "session",
|
|
code: "invalid-session-json",
|
|
count: 1,
|
|
rows: [3],
|
|
message: "Skipped a session JSONL row that is not valid JSON.",
|
|
},
|
|
]);
|
|
expect(
|
|
JSON.parse(fs.readFileSync(path.join(outputDir, "manifest.json"), "utf8")).warnings,
|
|
).toEqual(bundle.manifest.warnings);
|
|
});
|
|
|
|
it("reports incomplete transcript branches while exporting the reachable suffix", async () => {
|
|
const tmpDir = makeTempDir();
|
|
const sessionFile = path.join(tmpDir, "session.jsonl");
|
|
const outputDir = path.join(tmpDir, "bundle");
|
|
fs.writeFileSync(
|
|
sessionFile,
|
|
[
|
|
JSON.stringify({
|
|
type: "session",
|
|
version: 3,
|
|
id: "session-1",
|
|
timestamp: "2026-04-01T05:46:39.000Z",
|
|
cwd: tmpDir,
|
|
}),
|
|
JSON.stringify({
|
|
type: "message",
|
|
id: "orphan-tail",
|
|
parentId: "missing-imported-parent",
|
|
timestamp: "2026-04-01T05:46:40.000Z",
|
|
message: assistantMessage([{ type: "text", text: "reachable tail" }]),
|
|
}),
|
|
].join("\n") + "\n",
|
|
"utf8",
|
|
);
|
|
|
|
const bundle = await exportTrajectoryBundle({
|
|
outputDir,
|
|
sessionFile,
|
|
sessionId: "session-1",
|
|
workspaceDir: tmpDir,
|
|
});
|
|
|
|
expect(eventTypes(bundle.events)).toEqual(["assistant.message"]);
|
|
expect(bundle.manifest.warnings).toEqual([
|
|
{
|
|
source: "session",
|
|
code: "incomplete-session-branch",
|
|
count: 1,
|
|
rows: [2],
|
|
message: "Exported the reachable session branch suffix after a missing parent link.",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("stops cyclic transcript branch export instead of hanging", async () => {
|
|
const tmpDir = makeTempDir();
|
|
const sessionFile = path.join(tmpDir, "session.jsonl");
|
|
const outputDir = path.join(tmpDir, "bundle");
|
|
fs.writeFileSync(
|
|
sessionFile,
|
|
[
|
|
JSON.stringify({
|
|
type: "session",
|
|
version: 3,
|
|
id: "session-1",
|
|
timestamp: "2026-04-01T05:46:39.000Z",
|
|
cwd: tmpDir,
|
|
}),
|
|
JSON.stringify({
|
|
type: "message",
|
|
id: "entry-a",
|
|
parentId: "entry-b",
|
|
timestamp: "2026-04-01T05:46:40.000Z",
|
|
message: userMessage("cycle a"),
|
|
}),
|
|
JSON.stringify({
|
|
type: "message",
|
|
id: "entry-b",
|
|
parentId: "entry-a",
|
|
timestamp: "2026-04-01T05:46:41.000Z",
|
|
message: assistantMessage([{ type: "text", text: "cycle b" }]),
|
|
}),
|
|
].join("\n") + "\n",
|
|
"utf8",
|
|
);
|
|
|
|
const bundle = await exportTrajectoryBundle({
|
|
outputDir,
|
|
sessionFile,
|
|
sessionId: "session-1",
|
|
workspaceDir: tmpDir,
|
|
});
|
|
|
|
expect(eventTypes(bundle.events)).toEqual(["user.message", "assistant.message"]);
|
|
expect(bundle.manifest.warnings).toEqual([
|
|
{
|
|
source: "session",
|
|
code: "cyclic-session-branch",
|
|
count: 1,
|
|
rows: [3],
|
|
message: "Stopped trajectory session branch export at a cyclic parent link.",
|
|
},
|
|
]);
|
|
});
|
|
|
|
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(eventTypes(bundle.events)).toContain("recorded-runtime");
|
|
expect(eventTypes(bundle.events)).not.toContain("env-runtime");
|
|
} 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(eventTypes(bundle.events)).not.toContain("outside-runtime");
|
|
});
|
|
|
|
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(eventTypes(bundle.events)).not.toContain("symlink-runtime");
|
|
});
|
|
|
|
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(eventTypes(bundle.events)).not.toContain("other-runtime");
|
|
expect(bundle.manifest.warnings).toBeUndefined();
|
|
});
|
|
|
|
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",
|
|
terminalError: "non_deliverable_terminal_turn",
|
|
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);
|
|
const types = eventTypes(exportedEvents);
|
|
expect(types).toContain("tool.call");
|
|
expect(types).toContain("tool.result");
|
|
expect(types).toContain("context.compiled");
|
|
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",
|
|
]);
|
|
const emptyContents = (manifest.contents ?? []).filter((entry) => entry.bytes <= 0);
|
|
expect(emptyContents).toStrictEqual([]);
|
|
|
|
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]?.id).toBe("weather");
|
|
expect(metadata.skills?.entries?.[0]?.invoked).toBe(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(artifacts).toContain("non_deliverable_terminal_turn");
|
|
expect(systemPrompt).toContain("$WORKSPACE_DIR/instructions.md");
|
|
expect(tools).toContain("$WORKSPACE_DIR/docs");
|
|
expect(`${prompts}\n${artifacts}\n${systemPrompt}\n${tools}`).not.toContain(tmpDir);
|
|
});
|
|
});
|