fix(cli): emit JSON for trajectory export failures (#128351)

* fix(cli): render trajectory export JSON failures

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

* fix(cli): route invalid trajectory stores through root errors

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
xingzhou
2026-08-24 08:57:03 +08:00
committed by GitHub
parent ab94c85dfd
commit bbb644353d
4 changed files with 384 additions and 96 deletions
+153 -57
View File
@@ -1,5 +1,6 @@
// Export trajectory tests cover trajectory export command output and file selection.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ExpectedCliError } from "../cli/failure-output.js";
import type { RuntimeEnv } from "../runtime.js";
import { exportTrajectoryCommand } from "./export-trajectory.js";
@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({
getRuntimeConfig: vi.fn(),
loadSessionEntryReadOnly: vi.fn(),
resolveExplicitStorePath: vi.fn(),
resolveSessionTranscriptReadTarget: vi.fn(),
resolveStorePath: vi.fn(),
}));
@@ -18,9 +20,13 @@ vi.mock("../config/config.js", () => ({
vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions/session-accessor.js")>();
mocks.resolveSessionTranscriptReadTarget.mockImplementation(
actual.resolveSessionTranscriptReadTarget,
);
return {
...actual,
loadSessionEntryReadOnly: mocks.loadSessionEntryReadOnly,
resolveSessionTranscriptReadTarget: mocks.resolveSessionTranscriptReadTarget,
};
});
@@ -38,7 +44,7 @@ vi.mock("../config/sessions/paths.js", async (importOriginal) => {
});
vi.mock("./session-store-targets.js", () => ({
resolveExplicitSessionStorePathOrExit: mocks.resolveExplicitStorePath,
resolveExplicitSessionStorePath: mocks.resolveExplicitStorePath,
}));
function createRuntime(): RuntimeEnv {
@@ -49,6 +55,22 @@ function createRuntime(): RuntimeEnv {
} as unknown as RuntimeEnv;
}
async function expectTrajectoryFailure(
execution: Promise<void>,
runtime: RuntimeEnv,
message: string,
) {
await expect(execution).rejects.toBeInstanceOf(ExpectedCliError);
await expect(execution).rejects.toMatchObject({
message,
humanOutput: message,
machineOutput: message,
});
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalled();
}
describe("exportTrajectoryCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -57,7 +79,7 @@ describe("exportTrajectoryCommand", () => {
mocks.resolveExplicitStorePath.mockImplementation(
(params: { storePath: string }) => params.storePath,
);
mocks.loadSessionEntryReadOnly.mockReturnValue(undefined);
mocks.loadSessionEntryReadOnly.mockReturnValue({ sessionId: "session-1", updatedAt: 1 });
mocks.exportTrajectoryForCommand.mockResolvedValue({
outputDir: "/tmp/workspace/.openclaw/trajectory-exports/export",
displayPath: ".openclaw/trajectory-exports/export",
@@ -73,44 +95,57 @@ describe("exportTrajectoryCommand", () => {
it("points missing session key users at the sessions command", async () => {
const runtime = createRuntime();
await exportTrajectoryCommand({}, runtime);
expect(runtime.error).toHaveBeenCalledWith(
await expectTrajectoryFailure(
exportTrajectoryCommand({}, runtime),
runtime,
"--session-key is required. Run openclaw sessions to choose a session.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("reports malformed encoded request JSON without leaking parser output", async () => {
const runtime = createRuntime();
const requestJsonBase64 = Buffer.from("not json", "utf8").toString("base64url");
await exportTrajectoryCommand({ requestJsonBase64 }, runtime);
expect(runtime.error).toHaveBeenCalledWith(
"Failed to decode trajectory export request: Encoded trajectory export request is invalid JSON",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.resolveStorePath).not.toHaveBeenCalled();
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
});
it.each([
["a discarded suffix", (encoded: string) => `${encoded}A`, "x"],
["nonzero padding bits", (encoded: string) => `${encoded.slice(0, -1)}R`, "xy"],
["surrounding whitespace", (encoded: string) => ` ${encoded} `, "xyz"],
{
name: "malformed JSON",
encoded: Buffer.from("not json", "utf8").toString("base64url"),
detail: "Encoded trajectory export request is invalid JSON",
},
{
name: "a non-object JSON value",
encoded: Buffer.from("[]", "utf8").toString("base64url"),
detail: "Encoded trajectory export request must be a JSON object",
},
{
name: "a discarded suffix",
encoded: `${Buffer.from(JSON.stringify({ sessionKey: "x" }), "utf8").toString("base64url")}A`,
detail: "Encoded trajectory export request is invalid",
},
{
name: "nonzero padding bits",
encoded: `${Buffer.from(JSON.stringify({ sessionKey: "xy" }), "utf8")
.toString("base64url")
.slice(0, -1)}R`,
detail: "Encoded trajectory export request is invalid",
},
{
name: "surrounding whitespace",
encoded: ` ${Buffer.from(JSON.stringify({ sessionKey: "xyz" }), "utf8").toString("base64url")} `,
detail: "Encoded trajectory export request is invalid",
},
])(
"rejects a non-canonical base64url request with %s before looking up its session",
async (_case, makeNonCanonical, sessionKey) => {
"rejects an encoded request containing $name before looking up its session",
async ({ encoded, detail }) => {
const runtime = createRuntime();
const canonical = Buffer.from(JSON.stringify({ sessionKey }), "utf8").toString("base64url");
const requestJsonBase64 = makeNonCanonical(canonical);
await exportTrajectoryCommand({ requestJsonBase64 }, runtime);
expect(runtime.error).toHaveBeenCalledWith(
"Failed to decode trajectory export request: Encoded trajectory export request is invalid",
await expectTrajectoryFailure(
exportTrajectoryCommand({ requestJsonBase64: encoded }, runtime),
runtime,
`Failed to decode trajectory export request: ${detail}`,
);
expect(mocks.resolveStorePath).not.toHaveBeenCalled();
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
},
);
@@ -140,10 +175,9 @@ describe("exportTrajectoryCommand", () => {
sessionKey: "agent:main:telegram:direct:123",
storePath: "/tmp/direct-store.json",
});
expect(runtime.error).toHaveBeenCalledWith(
"Session not found: agent:main:telegram:direct:123. Run openclaw sessions to see available sessions.",
expect(mocks.exportTrajectoryForCommand).toHaveBeenCalledWith(
expect.objectContaining({ outputPath: "/tmp/export.json" }),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it.each([
@@ -158,12 +192,38 @@ describe("exportTrajectoryCommand", () => {
const runtime = createRuntime();
mocks.getRuntimeConfig.mockReturnValue({ agents: { list: [{ id: "main" }] } });
await exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123", agent }, runtime);
expect(runtime.error).toHaveBeenCalledWith(message);
expect(runtime.exit).toHaveBeenCalledWith(1);
await expectTrajectoryFailure(
exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123", agent }, runtime),
runtime,
message,
);
expect(mocks.resolveStorePath).not.toHaveBeenCalled();
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
});
it("routes invalid explicit stores through the command failure owner", async () => {
const runtime = createRuntime();
mocks.resolveStorePath.mockReturnValue("/tmp/missing.sqlite");
mocks.resolveExplicitStorePath.mockImplementationOnce(() => {
throw new Error("Session store target does not exist: /tmp/missing.sqlite");
});
await expectTrajectoryFailure(
exportTrajectoryCommand(
{
sessionKey: "agent:main:telegram:direct:123",
store: "/tmp/missing.sqlite",
json: true,
},
runtime,
),
runtime,
"Session store target does not exist: /tmp/missing.sqlite",
);
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
});
it("keeps a configured explicit agent as the session store owner", async () => {
@@ -214,18 +274,12 @@ describe("exportTrajectoryCommand", () => {
storePath: resolvedStore,
inputStorePath: store,
agentId: "work",
runtime,
json: undefined,
});
expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({
agentId: "work",
sessionKey: "agent:work:telegram:direct:123",
storePath: resolvedStore,
});
expect(runtime.error).toHaveBeenCalledWith(
"Session not found: agent:work:telegram:direct:123. Run openclaw sessions to see available sessions.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
},
);
@@ -247,10 +301,6 @@ describe("exportTrajectoryCommand", () => {
sessionKey: "agent:work:telegram:direct:123",
storePath: "/tmp/openclaw/agents/work/sessions/sessions.json",
});
expect(runtime.error).toHaveBeenCalledWith(
"Session not found: agent:work:telegram:direct:123. Run openclaw sessions to see available sessions.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("falls back through resolveStorePath when no session.store is configured", async () => {
@@ -264,10 +314,6 @@ describe("exportTrajectoryCommand", () => {
sessionKey: "agent:main:telegram:direct:123",
storePath: "/tmp/openclaw/sessions.json",
});
expect(runtime.error).toHaveBeenCalledWith(
"Session not found: agent:main:telegram:direct:123. Run openclaw sessions to see available sessions.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("passes blank configured session.store through the default-store resolver", async () => {
@@ -282,18 +328,52 @@ describe("exportTrajectoryCommand", () => {
sessionKey: "agent:main:telegram:direct:123",
storePath: "/tmp/openclaw/sessions.json",
});
expect(runtime.error).toHaveBeenCalledWith(
});
it("reports a missing session without resolving its transcript or exporting", async () => {
const runtime = createRuntime();
mocks.loadSessionEntryReadOnly.mockReturnValue(undefined);
await expectTrajectoryFailure(
exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime),
runtime,
"Session not found: agent:main:telegram:direct:123. Run openclaw sessions to see available sessions.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.resolveSessionTranscriptReadTarget).not.toHaveBeenCalled();
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
});
it("reports transcript target resolution failures without invoking the exporter", async () => {
const runtime = createRuntime();
mocks.resolveSessionTranscriptReadTarget.mockImplementationOnce(() => {
throw new Error("transcript target is unavailable");
});
await expectTrajectoryFailure(
exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime),
runtime,
"Failed to resolve session file: transcript target is unavailable",
);
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
});
it("reports exporter failures without formatting a successful result", async () => {
const runtime = createRuntime();
mocks.exportTrajectoryForCommand.mockRejectedValueOnce(new Error("workspace is unavailable"));
await expectTrajectoryFailure(
exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime),
runtime,
"Failed to export trajectory: workspace is unavailable",
);
expect(mocks.formatTrajectoryCommandExportSummary).not.toHaveBeenCalled();
});
it("exports SQLite sessions without probing a transcript JSONL file", async () => {
const runtime = createRuntime();
mocks.loadSessionEntryReadOnly.mockReturnValue({
sessionId: "session-1",
updatedAt: 1,
});
await exportTrajectoryCommand(
{
@@ -319,4 +399,20 @@ describe("exportTrajectoryCommand", () => {
expect(runtime.exit).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith("trajectory exported");
});
it("preserves successful JSON output", async () => {
const runtime = createRuntime();
await exportTrajectoryCommand(
{ sessionKey: "agent:main:telegram:direct:123", json: true },
runtime,
);
expect(runtime.log).toHaveBeenCalledWith(
JSON.stringify(await mocks.exportTrajectoryForCommand.mock.results[0]?.value, null, 2),
);
expect(mocks.formatTrajectoryCommandExportSummary).not.toHaveBeenCalled();
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
});
+23 -32
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js";
import { formatCliCommand } from "../cli/command-format.js";
import { ExpectedCliError } from "../cli/failure-output.js";
import { getRuntimeConfig } from "../config/config.js";
import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
import {
@@ -17,7 +18,7 @@ import {
formatTrajectoryCommandExportSummary,
type TrajectoryCommandExportSummary,
} from "../trajectory/command-export.js";
import { resolveExplicitSessionStorePathOrExit } from "./session-store-targets.js";
import { resolveExplicitSessionStorePath } from "./session-store-targets.js";
type ExportTrajectoryCommandOptions = {
sessionKey?: string;
@@ -94,6 +95,10 @@ function resolveExportTrajectoryOptions(
};
}
function throwTrajectoryExportError(message: string): never {
throw new ExpectedCliError({ message, humanOutput: message, machineOutput: message });
}
/** Resolves the requested session and exports its trajectory summary or JSON result. */
export async function exportTrajectoryCommand(
opts: ExportTrajectoryCommandOptions,
@@ -103,49 +108,41 @@ export async function exportTrajectoryCommand(
try {
resolvedOpts = resolveExportTrajectoryOptions(opts);
} catch (error) {
runtime.error(`Failed to decode trajectory export request: ${formatErrorMessage(error)}`);
runtime.exit(1);
return;
throwTrajectoryExportError(
`Failed to decode trajectory export request: ${formatErrorMessage(error)}`,
);
}
const sessionKey = resolvedOpts.sessionKey?.trim();
if (!sessionKey) {
runtime.error(
throwTrajectoryExportError(
`--session-key is required. Run ${formatCliCommand("openclaw sessions")} to choose a session.`,
);
runtime.exit(1);
return;
}
const requestedAgent = resolvedOpts.agent?.trim();
if (resolvedOpts.agent !== undefined && !requestedAgent) {
runtime.error("--agent must not be blank");
runtime.exit(1);
return;
throwTrajectoryExportError("--agent must not be blank");
}
let targetAgentId = resolveAgentIdFromSessionKey(sessionKey);
if (requestedAgent) {
try {
targetAgentId = resolveConfiguredAgentId(getRuntimeConfig(), requestedAgent);
} catch (error) {
runtime.error(formatErrorMessage(error));
runtime.exit(1);
return;
throwTrajectoryExportError(formatErrorMessage(error));
}
}
let storePath = resolvedOpts.store
? resolveSessionStorePathCore(resolvedOpts.store, { agentId: targetAgentId })
: resolveSessionStorePathCore(getRuntimeConfig().session?.store, { agentId: targetAgentId });
if (resolvedOpts.store) {
const explicitStorePath = resolveExplicitSessionStorePathOrExit({
storePath,
inputStorePath: resolvedOpts.store,
agentId: targetAgentId ?? "main",
runtime,
json: resolvedOpts.json,
});
if (!explicitStorePath) {
return;
try {
storePath = resolveExplicitSessionStorePath({
storePath,
inputStorePath: resolvedOpts.store,
agentId: targetAgentId ?? "main",
});
} catch (error) {
throwTrajectoryExportError(formatErrorMessage(error));
}
storePath = explicitStorePath;
}
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
const entry = loadSessionEntryReadOnly({
@@ -154,11 +151,9 @@ export async function exportTrajectoryCommand(
storePath,
});
if (!entry?.sessionId) {
runtime.error(
throwTrajectoryExportError(
`Session not found: ${sessionKey}. Run ${formatCliCommand("openclaw sessions")} to see available sessions.`,
);
runtime.exit(1);
return;
}
let sessionTarget: ReturnType<typeof resolveSessionTranscriptReadTarget>;
@@ -171,9 +166,7 @@ export async function exportTrajectoryCommand(
storePath,
});
} catch (error) {
runtime.error(`Failed to resolve session file: ${formatErrorMessage(error)}`);
runtime.exit(1);
return;
throwTrajectoryExportError(`Failed to resolve session file: ${formatErrorMessage(error)}`);
}
let summary: TrajectoryCommandExportSummary;
try {
@@ -190,9 +183,7 @@ export async function exportTrajectoryCommand(
workspaceDir: path.resolve(resolvedOpts.workspace ?? process.cwd()),
});
} catch (error) {
runtime.error(`Failed to export trajectory: ${formatErrorMessage(error)}`);
runtime.exit(1);
return;
throwTrajectoryExportError(`Failed to export trajectory: ${formatErrorMessage(error)}`);
}
if (resolvedOpts.json) {
+3 -3
View File
@@ -33,7 +33,7 @@ function formatResolvedStoreTarget(params: {
: `${params.resolvedPath} (resolved from --store ${JSON.stringify(params.inputStorePath)})`;
}
function validateExplicitSessionStorePath(params: {
export function resolveExplicitSessionStorePath(params: {
agentId: string;
inputStorePath: string;
storePath: string;
@@ -100,7 +100,7 @@ function validateExplicitSessionStorePath(params: {
}
/** Resolves and validates an operator-supplied legacy selector without changing its semantics. */
export function resolveExplicitSessionStorePathOrExit(params: {
function resolveExplicitSessionStorePathOrExit(params: {
storePath: string;
inputStorePath?: string;
agentId: string;
@@ -108,7 +108,7 @@ export function resolveExplicitSessionStorePathOrExit(params: {
json?: boolean;
}): string | null {
try {
return validateExplicitSessionStorePath({
return resolveExplicitSessionStorePath({
agentId: params.agentId,
inputStorePath: params.inputStorePath ?? params.storePath,
storePath: params.storePath,
+205 -4
View File
@@ -28,6 +28,29 @@ function runBuiltCli(tempHome: string, args: string[], envOverrides: NodeJS.Proc
});
}
async function seedTrajectorySession(tempHome: string, sessionKey: string) {
const stateDir = path.join(tempHome, "isolated-state");
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: tempHome,
USERPROFILE: tempHome,
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
OPENCLAW_STATE_DIR: stateDir,
};
delete env.OPENCLAW_HOME;
const [{ upsertSessionEntryCore }, { closeOpenClawAgentDatabaseByPath }] = await Promise.all([
import("../src/config/sessions/session-accessor.js"),
import("../src/state/openclaw-agent-db.js"),
]);
await upsertSessionEntryCore(
{ agentId: "main", env, sessionKey },
{ sessionId: "trajectory-process-session", updatedAt: 1 },
);
closeOpenClawAgentDatabaseByPath(
path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"),
);
}
describe("cli json stdout contract", () => {
it.each([
{
@@ -427,6 +450,109 @@ describe("cli json stdout contract", () => {
message:
"`sessions export-trajectory` does not support the parent `sessions` option --all-agents; trajectory export targets one session and cannot apply session-list filters.",
},
{
name: "trajectory export missing session key in human mode",
args: ["sessions", "export-trajectory"],
message: "--session-key is required. Run openclaw sessions to choose a session.",
human: true,
},
{
name: "trajectory export missing session key with leaf JSON",
args: ["sessions", "export-trajectory", "--json"],
message: "--session-key is required. Run openclaw sessions to choose a session.",
},
{
name: "trajectory export missing session key with parent JSON through forced Commander",
args: ["sessions", "--json", "export-trajectory"],
message: "--session-key is required. Run openclaw sessions to choose a session.",
commander: true,
},
{
name: "trajectory export malformed encoded request",
args: [
"sessions",
"export-trajectory",
"--request-json-base64",
Buffer.from("not json", "utf8").toString("base64url"),
"--json",
],
message:
"Failed to decode trajectory export request: Encoded trajectory export request is invalid JSON",
},
{
name: "trajectory export noncanonical encoded request with parent JSON",
args: [
"sessions",
"--json",
"export-trajectory",
"--request-json-base64",
` ${Buffer.from(JSON.stringify({ sessionKey: "agent:main:test" })).toString("base64url")} `,
],
message:
"Failed to decode trajectory export request: Encoded trajectory export request is invalid",
},
{
name: "trajectory export blank explicit agent",
args: [
"sessions",
"export-trajectory",
"--session-key",
"agent:main:test",
"--agent",
"",
"--json",
],
message: "--agent must not be blank",
},
{
name: "trajectory export unconfigured explicit agent",
args: [
"sessions",
"export-trajectory",
"--session-key",
"agent:main:test",
"--agent",
"unknown-agent",
"--json",
],
message:
'Unknown agent id "unknown-agent". Run openclaw agents list to see configured agents.',
},
{
name: "trajectory export missing session through dual-TTY finalization",
args: ["sessions", "export-trajectory", "--session-key", "agent:main:missing", "--json"],
message:
"Session not found: agent:main:missing. Run openclaw sessions to see available sessions.",
tty: true,
},
{
name: "trajectory export invalid explicit store",
args: [
"sessions",
"export-trajectory",
"--session-key",
"agent:main:trajectory-process",
"--store",
"$MISSING_STORE",
"--json",
],
message:
"Session store target does not exist: $MISSING_STORE. Pass a selector whose resolved SQLite target exists.",
},
{
name: "trajectory exporter operational failure",
args: [
"sessions",
"export-trajectory",
"--session-key",
"agent:main:trajectory-process",
"--workspace",
"$TRAJECTORY_WORKSPACE",
"--json",
],
message: "Failed to export trajectory: injected trajectory exporter failure",
exporterFailure: true,
},
{
name: "archive inherited store with leaf JSON",
args: ["sessions", "--store", "/tmp/other.sqlite", "archive", "agent:main:test", "--json"],
@@ -487,11 +613,21 @@ describe("cli json stdout contract", () => {
])("renders sessions list and registration validation failures for $name", async (testCase) => {
await withTempHome(
async (tempHome) => {
if ("exporterFailure" in testCase) {
await seedTrajectorySession(tempHome, "agent:main:trajectory-process");
}
const preload = Buffer.from(
[
'import net from "node:net";',
'net.Socket.prototype.connect = function () { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };',
'globalThis.fetch = async () => { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };',
...("exporterFailure" in testCase
? [
'import fs from "node:fs/promises";',
"const originalRealpath = fs.realpath;",
`fs.realpath = async (target, ...args) => { if (target === ${JSON.stringify(tempHome)}) { throw new Error("injected trajectory exporter failure"); } return originalRealpath(target, ...args); };`,
]
: []),
...("tty" in testCase
? [
'Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });',
@@ -500,7 +636,16 @@ describe("cli json stdout contract", () => {
: []),
].join("\n"),
).toString("base64");
const result = runBuiltCli(tempHome, testCase.args, {
const missingStore = path.join(tempHome, "missing-store.sqlite");
const args = testCase.args.map((arg) =>
arg === "$TRAJECTORY_WORKSPACE"
? tempHome
: arg === "$MISSING_STORE"
? missingStore
: arg,
);
const message = testCase.message.replace("$MISSING_STORE", missingStore);
const result = runBuiltCli(tempHome, args, {
NODE_OPTIONS: `--import=data:text/javascript;base64,${preload}`,
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
OPENCLAW_GATEWAY_PORT: "29791",
@@ -517,11 +662,11 @@ describe("cli json stdout contract", () => {
} else {
expect(JSON.parse(result.stdout)).toEqual({
ok: false,
error: { type: "cli_error", message: testCase.message },
error: { type: "cli_error", message },
});
}
expect(result.stderr).toContain(testCase.message);
expect(result.stderr.split(testCase.message)).toHaveLength(2);
expect(result.stderr).toContain(message);
expect(result.stderr.split(message)).toHaveLength(2);
expect(result.stderr).not.toContain("AUTOQA_NETWORK_FORBIDDEN");
if ("tty" in testCase) {
expect(result.stderr).toContain("\u001B[?25h");
@@ -531,6 +676,62 @@ describe("cli json stdout contract", () => {
);
});
it.each([
{ name: "direct JSON export", encoded: false, json: true },
{ name: "encoded request precedence with plain output", encoded: true, json: false },
])("preserves successful trajectory $name", async (testCase) => {
await withTempHome(
async (tempHome) => {
const sessionKey = "agent:main:trajectory-process";
await seedTrajectorySession(tempHome, sessionKey);
const output = testCase.encoded ? "encoded-export" : "direct-export";
const args = [
"sessions",
"export-trajectory",
"--session-key",
testCase.encoded ? "agent:main:missing" : sessionKey,
"--output",
"direct-export",
"--workspace",
tempHome,
];
if (testCase.encoded) {
args.push(
"--request-json-base64",
Buffer.from(JSON.stringify({ sessionKey, output }), "utf8").toString("base64url"),
);
}
if (testCase.json) {
args.push("--json");
}
const result = runBuiltCli(tempHome, args, {
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
OPENCLAW_GATEWAY_PORT: "29791",
OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"),
});
expect(result.status, result.stderr).toBe(0);
if (testCase.json) {
expect(JSON.parse(result.stdout)).toMatchObject({
displayPath: `.openclaw/trajectory-exports/${output}`,
sessionId: "trajectory-process-session",
});
} else {
expect(result.stdout).toContain("✅ Trajectory exported!");
expect(result.stdout).toContain(`.openclaw/trajectory-exports/${output}`);
expect(result.stdout).toContain("trajectory-process-session");
}
await expect(
fs.access(
path.join(tempHome, ".openclaw", "trajectory-exports", output, "manifest.json"),
),
).resolves.toBeUndefined();
},
{ prefix: "openclaw-trajectory-success-e2e-" },
);
});
it.each([
{
name: "account validation in human mode",