Files
openclaw/extensions/codex/src/app-server/trajectory.test.ts
Josh Lehman 0a8e3604ba refactor: flip sessions and transcripts to sqlite storage (#98236)
* refactor(sessions): migrate runtime storage to sqlite

* test(sessions): fix sqlite CI regressions

* test(sessions): align remaining sqlite fixtures

* fix(codex): require sqlite trajectory recorder

* test(sessions): align orphan recovery sqlite fixture

* test(sessions): align sqlite rebase fixtures

* fix(sessions): finish current-main integration of the sqlite flip

Resolve the whole-store SDK removal across its owner boundary: drop the
loadSessionStore re-export and the registry whole-store wrappers, wire
hasTrackedActiveSessionRun into gateway chat, complete the
preserveLockedHarnessIds cleanup contract, flip the codex thread-history
import to storePath targets, and port remaining main-side tests from
file-store helpers to session accessor reads.

* chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs

Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore
the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain
bump gets its own review, and regenerate docs_map, the plugin SDK API baseline,
and the export-surface ratchet for the merged tree.

* feat(sessions): keep archived transcripts by default with zstd cold storage

Codex-style retention: deleting or resetting a session archives its
transcript as a zstd-compressed JSONL artifact (plain when the runtime
lacks node:zlib zstd) and keeps it until the disk budget evicts oldest
first. resetArchiveRetention now governs both deleted and reset archives
and defaults to keep; maxDiskBytes defaults to 2gb so retention stays
bounded, with archives evicted before live sessions. The cron reaper
follows the same knob instead of deleting archives on its own timer.

* fix(state): converge agent DB migration lineages and bound database growth

Merge coherence: run both structure-gated legacy memory-schema repairs
(flip-lineage drop, main-lineage identity rebuild) before the flip
migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all
converge, and hoist foreign_keys=OFF outside the schema transaction
where the pragma was silently ignored and the v1 sessions rebuild
cascade-deleted session_entries.

Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL
maintenance releases freed pages in bounded passes (never a blocking
full VACUUM), and doctor reports state/agent DB bloat from freelist
stats.

* fix(codex): resolve the store path for thread-history import via the SDK

The supervision catalog passed the legacy sessionFile locator to the
storePath-targeted transcript mirror; resolve the agent store path with
the session-store SDK helper instead of a runtime-object seam so test
fakes and headless callers need no extra surface. Drop the obsolete
missing-session-id preprocessing case: sessions rows are NOT NULL on
session_id and upsert repairs id-less patches at write time.

* fix(sessions): fail safe on malformed disk-budget config and doctor stat errors

A malformed explicit maxDiskBytes disables the budget instead of
falling back to the destructive 2gb default the user never chose, and
the doctor bloat check skips databases whose paths stat-fail instead of
aborting doctor.

* fix(sessions): complete sqlite conflict translations

* test(sqlite): align hardening checks with maintenance

* test(sessions): inspect compressed transcript archives

* fix(tests): await session seeds and drop unused helpers flagged by CI lint

The five unawaited writeSessionStoreSeed calls raced their SQLite seeds
against the assertions, failing compact shards; the bloat probe drops a
useless initializer and the merged tests drop now-unused helpers.

* test(sessions): type legacy proof events directly

* test(sessions): align hardening contracts

* perf(sessions): read usage transcript sizes from SQL aggregates

Usage/cost scans walked every session and materialized every transcript
event just to re-stringify it for a byte estimate — the #86718 stall
class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes
in SQLite without loading a single row.

* fix(sessions): re-root foreign-root transcript paths onto the current sessions dir

Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry
absolute sessionFile paths from the old root; the containment fallback
kept those foreign paths, so migration read (and would archive) files in
the original root and reported local copies missing. Re-root the
canonical agents/<id>/sessions suffix onto the current dir when the file
exists there; genuine cross-root layouts still fall through unchanged.

* test(agents): seed harness admission through sqlite

* fix(sqlite): close agent db on pragma setup failure

* fix(doctor): compact and retrofit incremental auto-vacuum after session import

The migration is the sanctioned offline window: post-import compact
reclaims import churn and applies auto_vacuum=INCREMENTAL to databases
created before the fresh-DB pragma existed, so runtime maintenance can
release pages in bounded passes on every install.

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-11 14:50:37 -07:00

331 lines
10 KiB
TypeScript

// Codex tests cover SQLite-only trajectory plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import {
appendSqliteTrajectoryRuntimeEvents,
loadSqliteTrajectoryRuntimeEvents,
type SqliteTrajectoryRuntimeEventForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
type CodexHostTrajectoryRecorder,
createCodexTrajectoryRecorder,
recordCodexTrajectoryCompletion,
recordCodexTrajectoryContext,
} from "./trajectory.js";
type CodexTrajectoryRecorder = NonNullable<ReturnType<typeof createCodexTrajectoryRecorder>>;
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-trajectory-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function expectTrajectoryRecorder(
recorder: ReturnType<typeof createCodexTrajectoryRecorder>,
): CodexTrajectoryRecorder {
if (recorder === null) {
throw new Error("Expected Codex trajectory recorder");
}
return recorder;
}
function createMemoryHostTrajectoryRecorder(): {
events: Array<{ type: string; data?: Record<string, unknown> }>;
recorder: CodexHostTrajectoryRecorder;
} {
const events: Array<{ type: string; data?: Record<string, unknown> }> = [];
return {
events,
recorder: {
recordEvent: (type, data) => events.push({ type, data }),
flush: async () => undefined,
},
};
}
function createMemoryBackedRecorder(params: {
tmpDir: string;
attempt?: Record<string, unknown>;
tools?: Parameters<typeof createCodexTrajectoryRecorder>[0]["tools"];
}): {
events: Array<{ type: string; data?: Record<string, unknown> }>;
recorder: CodexTrajectoryRecorder;
} {
const sessionId = (params.attempt?.sessionId as string | undefined) ?? "session-1";
const host = createMemoryHostTrajectoryRecorder();
const recorder = createCodexTrajectoryRecorder({
cwd: params.tmpDir,
attempt: {
sessionFile: path.join(params.tmpDir, "session.jsonl"),
sessionId,
sessionKey: `agent:main:${sessionId}`,
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
...params.attempt,
} as never,
trajectoryRecorder: host.recorder,
trajectorySessionFile: `sqlite:main:${sessionId}:${path.join(params.tmpDir, "sessions.json")}`,
tools: params.tools,
env: {},
});
return { events: host.events, recorder: expectTrajectoryRecorder(recorder) };
}
function createSqliteHostTrajectoryRecorder(params: {
agentId: string;
sessionId: string;
storePath: string;
}): CodexHostTrajectoryRecorder {
const events: SqliteTrajectoryRuntimeEventForTest[] = [];
let seq = 0;
return {
recordEvent: (type, data) => {
events.push({
traceSchema: "openclaw-trajectory",
schemaVersion: 1,
traceId: `${params.sessionId}:test`,
source: "runtime",
type,
ts: new Date(0).toISOString(),
seq,
sessionId: params.sessionId,
...(data === undefined ? {} : { data }),
});
seq += 1;
},
flush: async () => {
appendSqliteTrajectoryRuntimeEvents(params, events);
events.length = 0;
},
};
}
describe("Codex trajectory recorder", () => {
it("rejects file-backed trajectory targets without creating sidecars", () => {
const tmpDir = makeTempDir();
const warn = vi.fn();
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt: {
sessionFile: path.join(tmpDir, "session.jsonl"),
sessionId: "session-1",
model: { api: "responses" },
} as never,
env: {},
warn,
});
expect(recorder).toBeNull();
expect(warn).toHaveBeenCalledWith(
"codex trajectory capture requires a matching SQLite session target",
{ sessionId: "session-1", reason: "non-sqlite-session-target" },
);
expect(fs.existsSync(path.join(tmpDir, "session.trajectory.jsonl"))).toBe(false);
expect(fs.existsSync(path.join(tmpDir, "session.trajectory-path.json"))).toBe(false);
});
it("rejects a SQLite marker for a different session identity", () => {
const tmpDir = makeTempDir();
const warn = vi.fn();
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt: {
sessionFile: "sqlite:main:other:/tmp/openclaw-agent.sqlite",
sessionId: "session-1",
model: { api: "responses" },
} as never,
trajectoryRecorder: createMemoryHostTrajectoryRecorder().recorder,
env: {},
warn,
});
expect(recorder).toBeNull();
expect(warn).toHaveBeenCalledWith(
"codex trajectory capture requires a matching SQLite session target",
{ sessionId: "session-1", reason: "session-id-mismatch" },
);
});
it("warns when the SQLite host recorder is unavailable", () => {
const warn = vi.fn();
const recorder = createCodexTrajectoryRecorder({
cwd: makeTempDir(),
attempt: {
sessionFile: "sqlite:main:session-1:/tmp/openclaw-agent.sqlite",
sessionId: "session-1",
model: { api: "responses" },
} as never,
env: {},
warn,
});
expect(recorder).toBeNull();
expect(warn).toHaveBeenCalledWith(
"codex trajectory capture requires the SQLite host recorder",
{ sessionId: "session-1", reason: "sqlite-recorder-unavailable" },
);
});
it("stores SQLite-backed trajectory captures in the session database", async () => {
const tmpDir = makeTempDir();
const storePath = path.join(tmpDir, "sessions", "sessions.json");
const trajectorySessionFile = `sqlite:main:session-1:${storePath}`;
await upsertSessionEntry({
agentId: "main",
sessionKey: "agent:main:session-1",
storePath,
entry: { sessionId: "session-1", sessionFile: trajectorySessionFile, updatedAt: 10 },
});
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt: {
sessionFile: path.join(tmpDir, "sessions", "session.jsonl"),
sessionId: "session-1",
model: { api: "responses" },
} as never,
trajectoryRecorder: createSqliteHostTrajectoryRecorder({
agentId: "main",
sessionId: "session-1",
storePath,
}),
trajectorySessionFile,
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
trajectoryRecorder.recordEvent("session.started");
await trajectoryRecorder.flush();
expect(fs.readdirSync(path.join(tmpDir, "sessions"))).not.toEqual(
expect.arrayContaining(["session.trajectory.jsonl", "session.trajectory-path.json"]),
);
await expect(
loadSqliteTrajectoryRuntimeEvents({ agentId: "main", sessionId: "session-1", storePath }),
).resolves.toEqual([expect.objectContaining({ type: "session.started" })]);
});
it("redacts secrets and keeps recorded strings UTF-16 safe", async () => {
const { events, recorder } = createMemoryBackedRecorder({ tmpDir: makeTempDir() });
recorder.recordEvent("model.output", {
text: `${"x".repeat(19_999)}😀`,
apiKey: "secret",
authorization: "Bearer sk-test-secret-token",
});
await recorder.flush();
expect(events[0]?.data?.text).toBe(`${"x".repeat(19_999)}`);
expect(events[0]?.data?.apiKey).toBe("<redacted>");
expect(events[0]?.data?.authorization).toBe("<redacted>");
});
it("records namespace dynamic tools as callable trajectory definitions", async () => {
const tools = [
{
type: "namespace" as const,
name: "openclaw",
description: "",
tools: [
{
type: "function" as const,
name: "web_search",
description: "Search the web.",
inputSchema: { type: "object" },
deferLoading: true,
},
],
},
];
const tmpDir = makeTempDir();
const init = createMemoryBackedRecorder({ tmpDir, tools });
recordCodexTrajectoryContext(init.recorder, { attempt: {} as never, cwd: tmpDir, tools });
await init.recorder.flush();
expect(init.events[0]?.data?.tools).toEqual([
{
name: "web_search",
description: "Search the web.",
parameters: { type: "object" },
},
]);
});
it("honors explicit disablement without warning", () => {
const warn = vi.fn();
const recorder = createCodexTrajectoryRecorder({
cwd: makeTempDir(),
attempt: {
sessionFile: "sqlite:main:session-1:/tmp/openclaw-agent.sqlite",
sessionId: "session-1",
model: { api: "responses" },
} as never,
env: { OPENCLAW_TRAJECTORY: "0" },
warn,
});
expect(recorder).toBeNull();
expect(warn).not.toHaveBeenCalled();
});
it("preserves usage when truncating oversized model completion events", async () => {
const attempt = {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
const usage = {
input: 384_954,
output: 5_624,
cacheRead: 333_824,
reasoningTokens: 2_038,
total: 724_402,
};
const { events, recorder } = createMemoryBackedRecorder({
tmpDir: makeTempDir(),
attempt,
});
recordCodexTrajectoryCompletion(recorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
aborted: false,
attemptUsage: usage,
assistantTexts: ["done"],
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
await recorder.flush();
expect(events[0]?.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
usage,
});
expect(events[0]?.data?.messagesSnapshot).toBeUndefined();
expect(events[0]?.data?.droppedFields).toContain("messagesSnapshot");
});
});