diff --git a/src/commands/export-trajectory.test.ts b/src/commands/export-trajectory.test.ts index 14b8e8700437..dfd32e092aec 100644 --- a/src/commands/export-trajectory.test.ts +++ b/src/commands/export-trajectory.test.ts @@ -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(); + 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, + 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(); + }); }); diff --git a/src/commands/export-trajectory.ts b/src/commands/export-trajectory.ts index 4383333d6bf9..ca16f983809b 100644 --- a/src/commands/export-trajectory.ts +++ b/src/commands/export-trajectory.ts @@ -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; @@ -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) { diff --git a/src/commands/session-store-targets.ts b/src/commands/session-store-targets.ts index 7a23fe437fdd..9c83272ff28b 100644 --- a/src/commands/session-store-targets.ts +++ b/src/commands/session-store-targets.ts @@ -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, diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index cab2d87f634a..a7895c250a59 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -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",