Files
openclaw/extensions/codex/src/app-server/attempt-context.test.ts
Peter Steinberger d93524d1cc fix(codex): route workspace memory through tools (#87383)
* fix(codex): route workspace memory through tools

* fix(codex): preserve extra memory bootstrap files

* fix(codex): support memory_get-only context routing

* fix(codex): only tool-route canonical workspace memory

* fix(codex): keep memory fallback for sandbox workspaces
2026-05-27 20:55:27 +01:00

236 lines
7.3 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
AgentMessage,
EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it } from "vitest";
import {
buildCodexWorkspaceBootstrapContext,
buildCodexSystemPromptReport,
readContextEngineThreadBootstrapProjection,
remapCodexContextFilePath,
resolveContextEngineBootstrapProjectionDecision,
shouldProjectMirroredHistoryForCodexStart,
} from "./attempt-context.js";
import type { CodexDynamicToolSpec } from "./protocol.js";
import type { CodexAppServerContextEngineBinding } from "./session-binding.js";
describe("Codex app-server attempt context", () => {
it("returns a run context report without deferred Codex dynamic tool schemas", () => {
const tools = [
{
name: "message",
description: "Send a message.",
inputSchema: {
type: "object",
properties: {
text: { type: "string" },
},
},
},
{
name: "web_search",
description: "Search the web.",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
},
},
deferLoading: true,
},
] as CodexDynamicToolSpec[];
const report = buildCodexSystemPromptReport({
attempt: {
sessionId: "session-1",
provider: "codex",
modelId: "gpt-5.4-codex",
} as EmbeddedRunAttemptParams,
sessionKey: "agent:main:session-1",
workspaceDir: path.join("tmp", "workspace"),
developerInstructions: "test developer instructions",
workspaceBootstrapContext: {
bootstrapFiles: [],
contextFiles: [],
promptContextFiles: [],
developerInstructionFiles: [],
heartbeatReferenceFiles: [],
},
skillsPrompt: "",
tools,
});
expect(report.source).toBe("run");
expect(report.provider).toBe("codex");
expect(report.model).toBe("gpt-5.4-codex");
expect(report.systemPrompt.chars).toBeGreaterThan(0);
expect(report.systemPrompt.hash).toMatch(/^[a-f0-9]{64}$/u);
expect(report.skills.hash).toMatch(/^[a-f0-9]{64}$/u);
const message = report.tools.entries.find((tool) => tool.name === "message");
const webSearch = report.tools.entries.find((tool) => tool.name === "web_search");
expect(message?.schemaChars).toBeGreaterThan(0);
expect(message?.summaryHash).toMatch(/^[a-f0-9]{64}$/u);
expect(message?.schemaHash).toMatch(/^[a-f0-9]{64}$/u);
expect(webSearch?.schemaChars).toBe(0);
expect(webSearch?.summaryHash).toMatch(/^[a-f0-9]{64}$/u);
expect(webSearch?.schemaHash).toMatch(/^[a-f0-9]{64}$/u);
expect(report.tools.schemaChars).toBe(message?.schemaChars);
});
it("keeps MEMORY.md injected when sandbox effective workspace differs", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-memory-workspace-"));
const sandboxWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-memory-sandbox-"));
const memorySummary = "Sandboxed turns need bounded memory fallback.";
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
const context = await buildCodexWorkspaceBootstrapContext({
params: {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
config: {
agents: {
defaults: {
workspace: workspaceDir,
},
},
},
} as EmbeddedRunAttemptParams,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: sandboxWorkspaceDir,
sessionKey: "agent:main:session-1",
sessionAgentId: "main",
memoryToolNames: ["memory_search", "memory_get"],
});
expect(context.memoryReferenceFiles).toEqual([]);
expect(context.promptContext).toContain(memorySummary);
expect(context.memoryToolRouted).toBe(false);
});
it("remaps Codex bootstrap files under dot-prefixed workspace directories", () => {
expect(
remapCodexContextFilePath({
file: {
path: "/real/workspace/..context/SOUL.md",
content: "Soul voice goes here.",
},
sourceWorkspaceDir: "/real/workspace",
targetWorkspaceDir: "/sandbox/workspace",
}),
).toEqual({
path: "/sandbox/workspace/..context/SOUL.md",
content: "Soul voice goes here.",
});
expect(
remapCodexContextFilePath({
file: {
path: "/outside/SOUL.md",
content: "outside",
},
sourceWorkspaceDir: "/real/workspace",
targetWorkspaceDir: "/sandbox/workspace",
}),
).toEqual({
path: "/outside/SOUL.md",
content: "outside",
});
});
it("projects mirrored history for transient native-disabled Codex threads", () => {
expect(
shouldProjectMirroredHistoryForCodexStart({
startupBinding: {
threadId: "thread-existing",
dynamicToolsFingerprint: "same-tools",
} as never,
dynamicToolsFingerprint: "same-tools",
historyMessages: [
{
role: "user",
content: "earlier request",
timestamp: Date.now(),
} as AgentMessage,
],
forceProject: true,
}),
).toBe(true);
expect(
shouldProjectMirroredHistoryForCodexStart({
startupBinding: {
threadId: "thread-existing",
dynamicToolsFingerprint: "same-tools",
} as never,
dynamicToolsFingerprint: "same-tools",
historyMessages: [
{
role: "assistant",
content: "earlier response",
timestamp: Date.now(),
} as unknown as AgentMessage,
],
forceProject: true,
}),
).toBe(false);
});
it("reads and compares thread-bootstrap context-engine projections", () => {
const projection = readContextEngineThreadBootstrapProjection({
mode: "thread_bootstrap",
epoch: " epoch-1 ",
fingerprint: " fingerprint-1 ",
});
expect(projection).toEqual({
mode: "thread_bootstrap",
epoch: "epoch-1",
fingerprint: "fingerprint-1",
});
const expectedBinding = {
schemaVersion: 1,
engineId: "lossless",
policyFingerprint: "policy-v1",
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
fingerprint: "fingerprint-1",
},
} satisfies CodexAppServerContextEngineBinding;
expect(
resolveContextEngineBootstrapProjectionDecision({
startupBinding: {
threadId: "thread-existing",
dynamicToolsFingerprint: "same-tools",
contextEngine: expectedBinding,
} as never,
expectedBinding,
projection: projection!,
dynamicToolsFingerprint: "same-tools",
}),
).toEqual({
project: false,
reason: "matching-thread-bootstrap-binding",
});
expect(
resolveContextEngineBootstrapProjectionDecision({
startupBinding: {
threadId: "thread-existing",
dynamicToolsFingerprint: "old-tools",
contextEngine: expectedBinding,
} as never,
expectedBinding,
projection: projection!,
dynamicToolsFingerprint: "new-tools",
}),
).toEqual({
project: true,
reason: "dynamic-tools-mismatch",
});
});
});