Files
openclaw/extensions/codex/src/app-server/trajectory.test.ts
Peter Steinberger e2643afb2a fix(codex): retain terminal facts in oversized trajectories (#126050)
* fix(codex): delegate trajectory bounding to host

* fix(trajectory): preserve compact terminal facts

* style: format release validation skill
2026-08-18 16:22:25 -07:00

428 lines
14 KiB
TypeScript

// Codex tests cover SQLite-only trajectory plugin behavior.
import fs from "node:fs";
import path from "node:path";
import { createAgentHarnessHostCapabilitiesForTest } from "openclaw/plugin-sdk/plugin-test-runtime";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import {
appendSqliteTrajectoryRuntimeEvents,
createTrajectoryRuntimeRecorderForTest,
exportTrajectoryBundleForTest,
loadSqliteTrajectoryRuntimeEvents,
type SqliteTrajectoryRuntimeEventForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import {
resolvePreferredOpenClawTmpDir,
tempWorkspaceSync,
type TempWorkspaceSync,
} from "openclaw/plugin-sdk/temp-path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createCodexTrajectoryRecorder,
recordCodexTrajectoryCompletion,
recordCodexTrajectoryContext,
} from "./trajectory.js";
type CodexTrajectoryRecorder = NonNullable<ReturnType<typeof createCodexTrajectoryRecorder>>;
type CodexTrajectoryFacade = NonNullable<
Parameters<typeof createCodexTrajectoryRecorder>[0]["trajectory"]
>;
let testWorkspace: TempWorkspaceSync;
beforeEach(() => {
testWorkspace = tempWorkspaceSync({
rootDir: resolvePreferredOpenClawTmpDir(),
prefix: "openclaw-codex-trajectory-",
});
});
afterEach(() => {
testWorkspace.cleanup();
});
function expectTrajectoryRecorder(
recorder: ReturnType<typeof createCodexTrajectoryRecorder>,
): CodexTrajectoryRecorder {
if (recorder === null) {
throw new Error("Expected Codex trajectory recorder");
}
return recorder;
}
function createMemoryTrajectoryFacade(): {
events: Array<{ type: string; data?: Record<string, unknown> }>;
trajectory: CodexTrajectoryFacade;
} {
const events: Array<{ type: string; data?: Record<string, unknown> }> = [];
return {
events,
trajectory: {
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 = createMemoryTrajectoryFacade();
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,
trajectory: host.trajectory,
tools: params.tools,
});
return { events: host.events, recorder: expectTrajectoryRecorder(recorder) };
}
function createSqliteTrajectoryFacade(params: {
agentId: string;
sessionId: string;
storePath: string;
}): CodexTrajectoryFacade {
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("returns null when the host trajectory facade is unavailable", () => {
expect(
createCodexTrajectoryRecorder({
cwd: testWorkspace.dir,
attempt: {
sessionFile: "agent:main:session-1",
sessionId: "session-1",
model: { api: "responses" },
} as never,
}),
).toBeNull();
});
it("stores SQLite-backed captures for the canonical session-key target", async () => {
// Regression: the host stopped emitting legacy `sqlite:` session-file
// markers, so any marker re-derivation here drops every Codex capture.
const tmpDir = testWorkspace.dir;
const storePath = path.join(tmpDir, "sessions", "sessions.json");
await upsertSessionEntry({
agentId: "main",
sessionKey: "agent:main:session-1",
storePath,
entry: { sessionId: "session-1", updatedAt: 10 },
});
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt: {
sessionFile: "agent:main:session-1",
sessionKey: "agent:main:session-1",
sessionId: "session-1",
model: { api: "responses" },
} as never,
trajectory: createSqliteTrajectoryFacade({
agentId: "main",
sessionId: "session-1",
storePath,
}),
});
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("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 = testWorkspace.dir;
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("lets the host bound oversized Codex events without losing terminal facts", async () => {
const tmpDir = testWorkspace.dir;
const storePath = path.join(tmpDir, "sessions", "sessions.json");
const sessionTarget = {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
storePath,
};
await upsertSessionEntry({
agentId: sessionTarget.agentId,
sessionKey: sessionTarget.sessionKey,
storePath,
entry: { sessionId: sessionTarget.sessionId, updatedAt: 10 },
});
const attempt = {
agentId: "main",
cwd: tmpDir,
workspaceDir: tmpDir,
sessionFile: sessionTarget.sessionKey,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
};
const usage = {
input: 384_954,
output: 5_624,
cacheRead: 333_824,
reasoningTokens: 2_038,
total: 724_402,
};
const hostRecorder = createTrajectoryRuntimeRecorderForTest({
sessionId: sessionTarget.sessionId,
sessionKey: sessionTarget.sessionKey,
sessionTarget,
runId: attempt.runId,
provider: "openai",
modelId: attempt.modelId,
modelApi: "responses",
workspaceDir: tmpDir,
});
if (!hostRecorder) {
throw new Error("Expected host trajectory recorder");
}
const host = await createAgentHarnessHostCapabilitiesForTest({
attempt: { ...attempt, trajectoryRecorder: hostRecorder } as never,
pluginId: "codex",
});
const recorder = createCodexTrajectoryRecorder({
attempt: attempt as never,
cwd: tmpDir,
trajectory: host.capabilities.trajectory,
} as never);
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
try {
recordCodexTrajectoryContext(trajectoryRecorder, {
attempt: attempt as never,
cwd: tmpDir,
developerInstructions: `Bearer ${"s".repeat(40)} ${"x".repeat(40_000)}`,
prompt: "inspect",
tools: [
{
type: "function",
name: "huge_tool",
description: "x".repeat(40_000),
inputSchema: { type: "object" },
},
],
} as never);
trajectoryRecorder.recordEvent("tool.result", {
toolCallId: "call-1",
toolName: "huge_tool",
status: "completed",
authorization: `Bearer ${"t".repeat(40)}`,
output: `token=${"t".repeat(40)} ${"x".repeat(40_000)}`,
});
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt: attempt as never,
threadId: "thread-1",
turnId: "turn-1",
timedOut: true,
yieldDetected: true,
result: {
terminal: {
kind: "timeout",
phase: "prompt",
source: "runtime",
aborted: true,
failure: { source: "prompt", error: "terminal prompt error" },
},
attemptUsage: usage,
assistantTexts: ["done"],
// Twelve entries stay below the old plugin's post-sanitization cap,
// but exceed the host cap when forwarded without pre-shrinking.
messagesSnapshot: Array.from({ length: 12 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt: attempt as never,
threadId: "thread-compact",
turnId: "turn-compact",
timedOut: true,
result: {
terminal: {
kind: "timeout",
phase: "prompt",
source: "runtime",
aborted: true,
failure: { source: "prompt", error: "compact prompt error" },
},
attemptUsage: usage,
assistantTexts: Array.from(
{ length: 12 },
(_value, index) => `assistant-${index} ${"x".repeat(32_000)}`,
),
messagesSnapshot: Array.from({ length: 12 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
await trajectoryRecorder.flush();
} finally {
host.close();
}
const events = await loadSqliteTrajectoryRuntimeEvents({
agentId: sessionTarget.agentId,
sessionId: sessionTarget.sessionId,
storePath,
});
const context = events.find((event) => event.type === "context.compiled");
const tool = events.find((event) => event.type === "tool.result");
const completion = events.find(
(event) => event.type === "model.completed" && event.data?.turnId === "turn-1",
);
const compactCompletion = events.find(
(event) => event.type === "model.completed" && event.data?.turnId === "turn-compact",
);
expect(context?.data).toMatchObject({
prompt: "inspect",
systemPrompt: {
truncated: true,
reason: "trajectory-field-size-limit",
},
tools: [
{
name: "huge_tool",
description: {
truncated: true,
reason: "trajectory-field-size-limit",
},
parameters: { type: "object" },
},
],
});
expect(JSON.stringify(context)).not.toContain("s".repeat(40));
expect(tool?.data).toMatchObject({
toolCallId: "call-1",
toolName: "huge_tool",
status: "completed",
output: {
truncated: true,
reason: "trajectory-field-size-limit",
},
});
expect(tool?.data?.authorization).toBeUndefined();
expect(JSON.stringify(tool)).not.toContain(`token=${"t".repeat(40)}`);
expect(completion?.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
threadId: "thread-1",
turnId: "turn-1",
timedOut: true,
yieldDetected: true,
aborted: true,
promptError: "terminal prompt error",
usage,
assistantTexts: ["done"],
});
expect(completion?.data?.messagesSnapshot).toBeUndefined();
expect(completion?.data?.droppedFields).toEqual(["messagesSnapshot"]);
expect(compactCompletion?.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
threadId: "thread-compact",
turnId: "turn-compact",
timedOut: true,
yieldDetected: false,
aborted: true,
promptError: "compact prompt error",
usage,
});
expect(compactCompletion?.data?.assistantTexts).toBeUndefined();
expect(compactCompletion?.data?.messagesSnapshot).toBeUndefined();
expect(compactCompletion?.data?.droppedFields).toEqual(["assistantTexts", "messagesSnapshot"]);
const bundle = await exportTrajectoryBundleForTest({
outputDir: path.join(tmpDir, "bundle"),
sessionTarget,
sessionId: sessionTarget.sessionId,
sessionKey: sessionTarget.sessionKey,
workspaceDir: tmpDir,
});
const exportedCompletion = bundle.events.find(
(event) => event.type === "model.completed" && event.data?.turnId === "turn-1",
);
const exportedCompactCompletion = bundle.events.find(
(event) => event.type === "model.completed" && event.data?.turnId === "turn-compact",
);
expect(exportedCompletion?.data).toEqual(completion?.data);
expect(exportedCompactCompletion?.data).toEqual(compactCompletion?.data);
});
});