fix(codex): respect lifecycle mismatch rotations

This commit is contained in:
Vincent Koc
2026-06-17 06:50:35 +02:00
parent 5b46a11d2d
commit da67802baf
7 changed files with 122 additions and 44 deletions

View File

@@ -10,7 +10,10 @@ import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
@@ -58,6 +61,25 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
} as EmbeddedRunAttemptParams;
}
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
function threadStartResult(threadId = "thread-auth-contract") {
return {
thread: {

View File

@@ -161,7 +161,7 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", ()
expectRecordFields(eventRecord, {
toolName: "exec",
toolCallId: "call-middleware",
args: { command: "status" },
args: mergedParams,
});
expectRecordFields(requireRecord(eventRecord.result, "tool_result middleware result"), {
content: [{ type: "text", text: "raw output" }],

View File

@@ -66,6 +66,25 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
} as EmbeddedRunAttemptParams;
}
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
function assistantMessage(text: string, timestamp: number): AgentMessage {
return {
role: "assistant",

View File

@@ -18,10 +18,32 @@ import {
tempDir,
} from "./run-attempt-test-harness.js";
import { testing } from "./run-attempt.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
setupRunAttemptTestHooks();
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
describe("runCodexAppServerAttempt native hook relay", () => {
it("registers native hook relay config for an enabled Codex turn and cleans it up", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
@@ -609,6 +631,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
nativeHookRelayGeneration: "generation-from-failed-resume",
});
const harness = createStartedThreadHarness(async (method) => {

View File

@@ -38,11 +38,30 @@ import { testing } from "./run-attempt.js";
import {
readCodexAppServerBinding,
resolveCodexAppServerBindingPath,
writeCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
setupRunAttemptTestHooks();
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
const tinyPngBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";

View File

@@ -426,20 +426,6 @@ export async function startOrResumeThread(params: {
}
binding = undefined;
}
if (
binding?.threadId &&
params.nativeCodeModeEnabled === false &&
!persistentWebSearchRestriction
) {
embeddedAgentLog.debug(
"codex app-server native tool surface disabled for turn; starting transient thread",
{
threadId: binding.threadId,
},
);
preserveExistingBinding = true;
binding = undefined;
}
if (binding?.threadId && (binding.contextEngine || contextEngineBinding)) {
if (
!contextEngineBinding ||
@@ -589,12 +575,25 @@ export async function startOrResumeThread(params: {
);
await clearCodexAppServerBinding(params.params.sessionFile);
}
} else if (
params.nativeCodeModeEnabled === false &&
!persistentWebSearchRestriction &&
binding.dynamicToolsFingerprint !== EMPTY_DYNAMIC_TOOLS_FINGERPRINT
) {
embeddedAgentLog.debug(
"codex app-server native tool surface disabled for turn; starting transient thread",
{
threadId: binding.threadId,
},
);
preserveExistingBinding = true;
} else {
const resumeBinding = binding;
try {
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
const authProfileId = params.params.authProfileId ?? resumeBinding.authProfileId;
const finalConfigPatch = params.buildFinalConfigPatch?.({
action: "resume",
binding,
binding: resumeBinding,
}) ?? {
configPatch: params.finalConfigPatch,
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
@@ -606,7 +605,7 @@ export async function startOrResumeThread(params: {
);
const resumeParams = lifecycleTiming.measureSync("thread-resume-params", () =>
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
threadId: resumeBinding.threadId,
authProfileId,
model: startModelSelection.model,
modelProvider: startModelProvider,
@@ -634,7 +633,7 @@ export async function startOrResumeThread(params: {
const nextMcpServersFingerprint =
params.mcpServersFingerprintEvaluated === true
? params.mcpServersFingerprint
: binding.mcpServersFingerprint;
: resumeBinding.mcpServersFingerprint;
await lifecycleTiming.measure("thread-resume-write-binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
@@ -650,13 +649,14 @@ export async function startOrResumeThread(params: {
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration:
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
finalConfigPatch.nativeHookRelayGeneration ??
resumeBinding.nativeHookRelayGeneration,
pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint,
pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint,
pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt: binding.createdAt,
createdAt: resumeBinding.createdAt,
},
{
authProfileStore: params.params.authProfileStore,
@@ -686,7 +686,7 @@ export async function startOrResumeThread(params: {
});
const activeTurnIds = readActiveCodexTurnIds(response.thread);
return {
...binding,
...resumeBinding,
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
@@ -698,10 +698,10 @@ export async function startOrResumeThread(params: {
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
nativeHookRelayGeneration:
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
finalConfigPatch.nativeHookRelayGeneration ?? resumeBinding.nativeHookRelayGeneration,
pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint,
pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint,
pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
lifecycle: {

View File

@@ -4,10 +4,7 @@ import { resetAdjustedParamsByToolCallIdForTests } from "../../../agents/agent-t
import type { AgentToolResult } from "../../../agents/runtime/index.js";
import { setToolTerminalPresentation } from "../../../agents/tool-terminal-presentation.js";
import type { AnyAgentTool } from "../../../agents/tools/common.js";
import type {
CodexAppServerExtensionFactory,
CodexAppServerToolResultEvent,
} from "../../../plugins/codex-app-server-extension-types.js";
import type { AgentToolResultMiddlewareEvent } from "../../../plugins/agent-tool-result-middleware-types.js";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
@@ -90,20 +87,18 @@ export function installOpenClawOwnedToolHooks(params?: {
* Pair with `installOpenClawOwnedToolHooks()` when a test asserts before/after hook behavior.
*/
export function installCodexToolResultMiddleware(
handler: (event: CodexAppServerToolResultEvent) => AgentToolResult<unknown>,
handler: (event: AgentToolResultMiddlewareEvent) => AgentToolResult<unknown>,
) {
const middleware = vi.fn(async (event: CodexAppServerToolResultEvent) => ({
const middleware = vi.fn(async (event: AgentToolResultMiddlewareEvent) => ({
result: handler(event),
}));
const registry = createEmptyPluginRegistry();
const factory: CodexAppServerExtensionFactory = async (codex) => {
codex.on("tool_result", middleware);
};
registry.codexAppServerExtensionFactories.push({
registry.agentToolResultMiddlewares.push({
pluginId: "runtime-contract",
pluginName: "Runtime Contract",
rawFactory: factory,
factory,
rawHandler: middleware,
handler: middleware,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);